├── .github ├── funding.yml └── workflows │ ├── build.yml │ ├── golangci-lint.yml │ └── publish-release.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── cli ├── branch.go ├── commands.go ├── log.go ├── rendering.go └── status.go ├── cmd └── gitin │ └── main.go ├── git ├── branch.go ├── commit.go ├── errors.go ├── repository.go ├── repository_test.go ├── status.go └── tag.go ├── go.mod ├── go.sum ├── patch └── git2go.v33.patch ├── prompt ├── async_list.go ├── list.go ├── prompt.go ├── renderer.go └── sync_list.go └── term ├── bufferedreader.go ├── bufferedwriter.go ├── consants_bsd.go ├── constants_common.go ├── constants_linux.go ├── keycodes.go ├── runereader.go └── terminal.go /.github/funding.yml: -------------------------------------------------------------------------------- 1 | patreon: isacikgoz 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | workflow_dispatch: 4 | name: Build 5 | jobs: 6 | build: 7 | env: 8 | GOPATH: ${{ github.workspace }} 9 | 10 | strategy: 11 | matrix: 12 | os: [ubuntu-latest, macos-latest] 13 | 14 | defaults: 15 | run: 16 | working-directory: ${{ env.GOPATH }}/src/github.com/${{ github.repository }} 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: Install Go 22 | uses: actions/setup-go@v2 23 | with: 24 | go-version: ${{ matrix.go-version }} 25 | - name: Checkout Code 26 | uses: actions/checkout@v2 27 | with: 28 | path: ${{ env.GOPATH }}/src/github.com/${{ github.repository }} 29 | - name: Build the tool 30 | run: export GITIN_FORCE_INSTALL=YES && make static 31 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: [push] 3 | permissions: 4 | contents: read 5 | jobs: 6 | golangci: 7 | name: lint 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: golangci-lint 12 | uses: golangci/golangci-lint-action@v2 13 | with: 14 | version: v1.50.1 15 | 16 | # Optional: if set to true then the action will use pre-installed Go. 17 | skip-go-installation: true 18 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | - name: Release 15 | uses: softprops/action-gh-release@v1 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | build -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | ## Filing an Issue 4 | Consider that `gitin` is at a very early stage of its development process. If you have a feature request, an idea, or a bug to report, please submit them [here](https://github.com/isacikgoz/gitin/issues). Please include a description sufficient to reproduce the bug you found, including tracebacks and reproduction steps, and please check for other reports of your bug before filing a new bug report. Duplicate issues does not help. 5 | 6 | ## Submitting Pull Request 7 | If you are fixing a bug: 8 | - Identify the bug 9 | - Description of the change 10 | - How did you conduct the verification process 11 | 12 | If you are adding a new feature/improving performance: 13 | - Description of the Change 14 | - Alternate Designs 15 | - Possible Drawbacks 16 | - How did you conduct the verification process 17 | 18 | Commits should: 19 | - Limit the first line to 72 characters or less 20 | - Use the present tense 21 | - Use the imperative mood 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2019, Ibrahim Serdar Acikgoz 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GOCMD=go 2 | 3 | BINARY?=gitin 4 | GITIN_SOURCE_DIR=cmd/gitin/main.go 5 | GITIN_BUILD_FLAGS=--tags static 6 | 7 | GITIN_DIR:=$(dir $(realpath $(lastword $(MAKEFILE_LIST)))) 8 | GOPATH_DIR?=$(shell go env GOPATH | cut -d: -f1) 9 | GOBIN_DIR:=$(GOPATH_DIR)/bin 10 | 11 | GIT2GO_VERSION=33 12 | PARENT_DIR=$(realpath $(GITIN_DIR)../) 13 | GIT2GO_DIR:=$(PARENT_DIR)/git2go 14 | LIBGIT2_DIR=$(GIT2GO_DIR)/vendor/libgit2 15 | GIT2GO_PATCH=patch/git2go.v$(GIT2GO_VERSION).patch 16 | 17 | all: $(BINARY) 18 | 19 | $(BINARY): build-libgit2 20 | $(GOCMD) build $(GITIN_BUILD_FLAGS) -o $(BINARY) $(GITIN_SOURCE_DIR) 21 | 22 | .PHONY: build-only 23 | build-only: 24 | make -C $(GIT2GO_DIR) install-static 25 | $(GOCMD) build $(GITIN_BUILD_FLAGS) -o $(BINARY) $(GITIN_SOURCE_DIR) 26 | 27 | .PHONY: build-libgit2 28 | build-libgit2: apply-patches 29 | make -C $(GIT2GO_DIR) install-static 30 | 31 | .PHONY: install 32 | install: $(BINARY) 33 | install -m755 -d $(GOBIN_DIR) 34 | install -m755 $(BINARY) $(GOBIN_DIR) 35 | 36 | .PHONY: update 37 | update: check-git2go 38 | git clone https://github.com/libgit2/git2go.git $(GIT2GO_DIR) 39 | cd $(GIT2GO_DIR) && git checkout v30.0.9 40 | cd $(GIT2GO_DIR) && git submodule -q foreach --recursive git reset -q --hard 41 | cd $(GIT2GO_DIR) && git submodule update --init --recursive 42 | 43 | check-git2go: 44 | @if [ "$(GITIN_FORCE_INSTALL)" == "YES" ]; then \ 45 | echo "removing by force"; \ 46 | rm -rf $(GIT2GO_DIR); \ 47 | elif [ -d "$(GIT2GO_DIR)" ]; then \ 48 | echo "$(GIT2GO_DIR) will be deleted, are you sure? [y/N] " && read ans && [ $${ans:-N} = y ]; \ 49 | if [ $$ans = y ] || [ $$ans = Y ] ; then \ 50 | rm -rf $(GIT2GO_DIR); \ 51 | fi; \ 52 | fi; \ 53 | 54 | .PHONY: apply-patches 55 | apply-patches: update 56 | if patch --dry-run -N -d $(GIT2GO_DIR) -p1 < $(GIT2GO_PATCH) >/dev/null; then \ 57 | patch -d $(GIT2GO_DIR) -p1 < $(GIT2GO_PATCH); \ 58 | fi 59 | 60 | .PHONY: static 61 | static: build-libgit2 62 | $(GOCMD) build $(GITIN_BUILD_FLAGS) -o $(BINARY) $(GITIN_SOURCE_DIR) 63 | 64 | .PHONY: clean 65 | clean: 66 | rm -f $(BINARY) 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](https://img.shields.io/github/actions/workflow/status/isacikgoz/gitin/build.yml) ![](https://img.shields.io/github/release-pre/isacikgoz/gitin.svg?style=flat) 2 | 3 | # gitin 4 | 5 | `gitin` is a commit/branch/status explorer for `git` 6 | 7 | gitin is a minimalist tool that lets you explore a git repository from the command line. You can search from commits, inspect individual files and changes in the commits. It is an alternative and interactive way to explore the commit history. Also, you can explore your current state by investigating diffs, stage your changes and commit them. 8 | 9 |

10 | screencast 11 |

12 | 13 | ## Features 14 | 15 | - Fuzzy search (type `/` to start a search after running `gitin `) 16 | - Interactive stage and see the diff of files (`gitin status` then press `enter` to see diff or `space` to stage) 17 | - Commit/amend changes (`gitin status` then press `c` to commit or `m` to amend) 18 | - Interactive hunk staging (`gitin status` then press `p`) 19 | - Explore branches with useful filter options (e.g. `gitin branch` press `enter` to checkout) 20 | - Convenient UX and minimalist design 21 | - See more options by running `gitin --help`, also you can get help for individual subcommands (e.g. `gitin log --help`) 22 | 23 | ## Installation 24 | 25 | - Linux and macOS are supported. 26 | - Download latest release from [here](https://github.com/isacikgoz/gitin/releases) 27 | - **Or**, manually download it with `go get -d github.com/isacikgoz/gitin/cmd/gitin` 28 | - `cd` into `$GOPATH/src/github.com/isacikgoz/gitin` 29 | - build with `make install` (`cmake` and `pkg-config` are required, also note that git2go will be cloned and built) 30 | 31 | ### Mac/Linux using brew 32 | 33 | The tap is recently moved to new repo, so if you added the older one (isacikgoz/gitin), consider removing it and adding the new one. 34 | 35 | ```sh 36 | brew tap isacikgoz/taps 37 | brew install gitin 38 | ``` 39 | 40 | ## Usage 41 | 42 | ```sh 43 | usage: gitin [] [ ...] 44 | 45 | Flags: 46 | -h, --help Show context-sensitive help (also try --help-long and --help-man). 47 | -v, --version Show application version. 48 | 49 | Commands: 50 | help [...] 51 | Show help. 52 | 53 | log 54 | Show commit logs. 55 | 56 | status 57 | Show working-tree status. Also stage and commit changes. 58 | 59 | branch 60 | Show list of branches. 61 | 62 | Environment Variables: 63 | 64 | GITIN_LINESIZE= 65 | GITIN_STARTINSEARCH= 67 | GITIN_VIMKEYS= 68 | 69 | Press ? for controls while application is running. 70 | 71 | ``` 72 | 73 | ## Configure 74 | 75 | - To set the line size `export GITIN_LINESIZE=5` 76 | - To set always start in search mode `GITIN_STARTINSEARCH=true` 77 | - To disable colors `GITIN_DISABLECOLOR=true` 78 | - To disable h,j,k,l for nav `GITIN_VIMKEYS=false` 79 | 80 | ## Development Requirements 81 | 82 | - **Running with static linking is highly recommended.** 83 | - Clone the project and `cd` into it. 84 | - Run `make build-libgit2` (this will satisfy the replace rule in the `go.mod` file) 85 | - You can run the project with `go run --tags static cmd/gitin/main.go --help` command 86 | 87 | ## Contribution 88 | 89 | - Contributions are welcome. If you like to please refer to [Contribution Guidelines](/CONTRIBUTING.md) 90 | - Bug reports should include descriptive steps to reproduce so that maintainers can easily understand the actual problem 91 | - Feature requests are welcome, ask for anything that seems appropriate 92 | 93 | ## Credits 94 | 95 | See the [credits page](https://github.com/isacikgoz/gitin/wiki/Credits) 96 | 97 | ## License 98 | 99 | [BSD-3-Clause](/LICENSE) 100 | -------------------------------------------------------------------------------- /cli/branch.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | 7 | "github.com/fatih/color" 8 | "github.com/isacikgoz/gitin/prompt" 9 | "github.com/isacikgoz/gitin/term" 10 | "github.com/isacikgoz/gitin/git" 11 | "github.com/justincampbell/timeago" 12 | ) 13 | 14 | // branch holds a list of items used to fill the terminal screen. 15 | type branch struct { 16 | repository *git.Repository 17 | prompt *prompt.Prompt 18 | } 19 | 20 | // BranchPrompt configures a prompt to serve as a branch prompt 21 | func BranchPrompt(r *git.Repository, opts *prompt.Options) (*prompt.Prompt, error) { 22 | branches, err := r.Branches() 23 | if err != nil { 24 | return nil, fmt.Errorf("could not load branches: %v", err) 25 | } 26 | list, err := prompt.NewList(branches, opts.LineSize) 27 | if err != nil { 28 | return nil, fmt.Errorf("could not create list: %v", err) 29 | } 30 | 31 | b := &branch{repository: r} 32 | b.prompt = prompt.Create("Branches", opts, list, 33 | prompt.WithSelectionHandler(b.onSelect), 34 | prompt.WithItemRenderer(renderItem), 35 | prompt.WithInformation(b.branchInfo), 36 | ) 37 | b.defineKeyBindings() 38 | 39 | return b.prompt, nil 40 | } 41 | 42 | func (b *branch) onSelect(item interface{}) error { 43 | branch := item.(*git.Branch) 44 | args := []string{"checkout", branch.Name} 45 | cmd := exec.Command("git", args...) 46 | cmd.Dir = b.repository.Path() 47 | if err := cmd.Run(); err != nil { 48 | return nil // possibly dirty branch 49 | } 50 | b.prompt.Stop() // quit after selection 51 | return nil 52 | } 53 | 54 | func (b *branch) defineKeyBindings() error { 55 | keybindings := []*prompt.KeyBinding{ 56 | &prompt.KeyBinding{ 57 | Key: 'd', 58 | Display: "d", 59 | Desc: "delete branch", 60 | Handler: b.deleteBranch, 61 | }, 62 | &prompt.KeyBinding{ 63 | Key: 'D', 64 | Display: "D", 65 | Desc: "force delete branch", 66 | Handler: b.forceDeleteBranch, 67 | }, 68 | &prompt.KeyBinding{ 69 | Key: 'q', 70 | Display: "q", 71 | Desc: "quit", 72 | Handler: b.quit, 73 | }, 74 | } 75 | for _, kb := range keybindings { 76 | if err := b.prompt.AddKeyBinding(kb); err != nil { 77 | return err 78 | } 79 | } 80 | return nil 81 | } 82 | 83 | func (b *branch) branchInfo(item interface{}) [][]term.Cell { 84 | branch := item.(*git.Branch) 85 | target := branch.Target() 86 | grid := make([][]term.Cell, 0) 87 | if target != nil { 88 | cells := term.Cprint("Last commit was ", color.Faint) 89 | cells = append(cells, term.Cprint(timeago.FromTime(target.Author.When), color.FgBlue)...) 90 | grid = append(grid, cells) 91 | if branch.IsRemote() { 92 | return grid 93 | } 94 | grid = append(grid, branchInfo(branch, false)...) 95 | } 96 | return grid 97 | } 98 | 99 | func (b *branch) deleteBranch(item interface{}) error { 100 | return b.bareDelete(item, "d") 101 | } 102 | 103 | func (b *branch) forceDeleteBranch(item interface{}) error { 104 | return b.bareDelete(item, "D") 105 | } 106 | 107 | func (b *branch) bareDelete(item interface{}, mode string) error { 108 | branch := item.(*git.Branch) 109 | cmd := exec.Command("git", "branch", "-"+mode, branch.Name) 110 | cmd.Dir = b.repository.Path() 111 | if err := cmd.Run(); err != nil { 112 | return nil // possibly an unmerged branch, just ignore it 113 | } 114 | return b.reloadBranches() 115 | } 116 | 117 | func (b *branch) quit(item interface{}) error { 118 | b.prompt.Stop() 119 | return nil 120 | } 121 | 122 | // reloads the list 123 | func (b *branch) reloadBranches() error { 124 | branches, err := b.repository.Branches() 125 | if err != nil { 126 | return err 127 | } 128 | state := b.prompt.State() 129 | list, err := prompt.NewList(branches, state.ListSize) 130 | if err != nil { 131 | return fmt.Errorf("could not reload branches: %v", err) 132 | } 133 | state.List = list 134 | b.prompt.SetState(state) 135 | // return err 136 | return nil 137 | } 138 | -------------------------------------------------------------------------------- /cli/commands.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | 7 | "github.com/isacikgoz/gitin/git" 8 | ) 9 | 10 | func popGitCommand(r *git.Repository, args []string) error { 11 | os.Setenv("LESS", "-RCS") 12 | cmd := exec.Command("git", args...) 13 | cmd.Dir = r.Path() 14 | 15 | cmd.Stdout = os.Stdout 16 | cmd.Stdin = os.Stdin 17 | 18 | if err := cmd.Start(); err != nil { 19 | return err 20 | } 21 | if err := cmd.Wait(); err != nil { 22 | return err 23 | } 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /cli/log.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/fatih/color" 9 | "github.com/isacikgoz/gitin/git" 10 | "github.com/isacikgoz/gitin/prompt" 11 | "github.com/isacikgoz/gitin/term" 12 | "github.com/justincampbell/timeago" 13 | ) 14 | 15 | // log holds the repository struct and the prompt pointer. since log and prompt dependent, 16 | // I found the best wau to associate them with this way 17 | type log struct { 18 | repository *git.Repository 19 | prompt *prompt.Prompt 20 | selected *git.Commit 21 | oldState *prompt.State 22 | } 23 | 24 | // LogPrompt configures a prompt to serve as a commit prompt 25 | func LogPrompt(r *git.Repository, opts *prompt.Options) (*prompt.Prompt, error) { 26 | commits, err := r.CommitsChan(0) 27 | if err != nil { 28 | return nil, fmt.Errorf("could not load commits: %v", err) 29 | } 30 | r.Branches() // to find refs 31 | r.Tags() 32 | items := make(chan interface{}) 33 | go func() { 34 | for c := range commits { 35 | items <- c 36 | } 37 | close(items) 38 | }() 39 | 40 | list, err := prompt.NewAsyncList(items, opts.LineSize) 41 | if err != nil { 42 | return nil, fmt.Errorf("could not create list: %v", err) 43 | } 44 | 45 | l := &log{repository: r} 46 | l.prompt = prompt.Create("Commits", opts, list, 47 | prompt.WithSelectionHandler(l.onSelect), 48 | prompt.WithItemRenderer(renderItem), 49 | prompt.WithInformation(l.logInfo), 50 | ) 51 | if err := l.defineKeybindings(); err != nil { 52 | return nil, err 53 | } 54 | 55 | return l.prompt, nil 56 | } 57 | 58 | // return true to terminate 59 | func (l *log) onSelect(item interface{}) error { 60 | switch item.(type) { 61 | case *git.Commit: // nolint:typecheck 62 | commit := item.(*git.Commit) 63 | l.selected = commit 64 | diff, err := commit.Diff() 65 | if err != nil { 66 | return nil 67 | } 68 | deltas := diff.Deltas() 69 | if len(deltas) <= 0 { 70 | return nil 71 | } 72 | 73 | l.oldState = l.prompt.State() 74 | list, err := prompt.NewList(deltas, 5) 75 | if err != nil { 76 | return err 77 | } 78 | l.prompt.SetState(&prompt.State{ 79 | List: list, 80 | SearchMode: false, 81 | SearchStr: "", 82 | SearchLabel: "Files", 83 | }) 84 | case *git.DiffDelta: 85 | if l.selected == nil { 86 | return nil 87 | } 88 | var args []string 89 | pid, err := l.selected.ParentID() 90 | if err != nil { 91 | args = []string{"show", "--oneline", "--patch"} 92 | } else { 93 | args = []string{"diff", pid + ".." + l.selected.Hash} 94 | } 95 | dd := item.(*git.DiffDelta) 96 | args = append(args, dd.OldFile.Path) 97 | if err := popGitCommand(l.repository, args); err != nil { 98 | //no err handling required here 99 | } 100 | } 101 | return nil 102 | } 103 | 104 | func (l *log) commitStat(item interface{}) error { 105 | commit, ok := item.(*git.Commit) 106 | if !ok { 107 | return nil 108 | } 109 | args := []string{"show", "--stat", commit.Hash} 110 | return popGitCommand(l.repository, args) 111 | } 112 | 113 | func (l *log) commitDiff(item interface{}) error { 114 | commit, ok := item.(*git.Commit) 115 | if !ok { 116 | return nil 117 | } 118 | args := []string{"show", commit.Hash} 119 | return popGitCommand(l.repository, args) 120 | } 121 | 122 | func (l *log) quit(item interface{}) error { 123 | switch item.(type) { 124 | case *git.Commit: // nolint: typecheck 125 | l.prompt.Stop() 126 | case *git.DiffDelta: 127 | l.prompt.SetState(l.oldState) 128 | } 129 | return nil 130 | } 131 | 132 | func (l *log) logInfo(item interface{}) [][]term.Cell { 133 | grid := make([][]term.Cell, 0) 134 | if item == nil { 135 | return grid 136 | } 137 | switch item.(type) { 138 | case *git.Commit: // nolint: typecheck 139 | commit := item.(*git.Commit) 140 | cells := term.Cprint("Author ", color.Faint) 141 | cells = append(cells, term.Cprint(commit.Author.Name+" <"+commit.Author.Email+">", color.FgWhite)...) 142 | grid = append(grid, cells) 143 | cells = term.Cprint("When", color.Faint) 144 | cells = append(cells, term.Cprint(" "+timeago.FromTime(commit.Author.When), color.FgWhite)...) 145 | grid = append(grid, cells) 146 | grid = append(grid, commitRefs(l.repository, commit)) 147 | return grid 148 | case *git.DiffDelta: 149 | dd := item.(*git.DiffDelta) 150 | var adds, dels int 151 | for _, line := range strings.Split(dd.Patch, "\n") { 152 | if len(line) > 0 { 153 | switch rn := line[0]; rn { 154 | case '+': 155 | adds++ 156 | case '-': 157 | dels++ 158 | } 159 | } 160 | } 161 | var cells []term.Cell 162 | if adds > 1 { 163 | cells = term.Cprint(strconv.Itoa(adds-1), color.FgGreen) 164 | cells = append(cells, term.Cprint(" additions", color.Faint)...) 165 | } 166 | if dels > 1 { 167 | if len(cells) > 1 { 168 | cells = append(cells, term.Cell{Ch: ' '}) 169 | } 170 | cells = append(cells, term.Cprint(strconv.Itoa(dels-1), color.FgRed)...) 171 | cells = append(cells, term.Cprint(" deletions", color.Faint)...) 172 | } 173 | if len(cells) > 1 { 174 | cells = append(cells, term.Cell{Ch: '.', Attr: []color.Attribute{color.Faint}}) 175 | } 176 | grid = append(grid, cells) 177 | } 178 | return grid 179 | } 180 | 181 | func (l *log) defineKeybindings() error { 182 | keybindings := []*prompt.KeyBinding{ 183 | &prompt.KeyBinding{ 184 | Key: 's', 185 | Display: "s", 186 | Desc: "show stat", 187 | Handler: l.commitStat, 188 | }, 189 | &prompt.KeyBinding{ 190 | Key: 'd', 191 | Display: "d", 192 | Desc: "show diff", 193 | Handler: l.commitDiff, 194 | }, 195 | &prompt.KeyBinding{ 196 | Key: 'q', 197 | Display: "q", 198 | Desc: "quit", 199 | Handler: l.quit, 200 | }, 201 | } 202 | for _, kb := range keybindings { 203 | if err := l.prompt.AddKeyBinding(kb); err != nil { 204 | return err 205 | } 206 | } 207 | return nil 208 | } 209 | 210 | func commitRefs(r *git.Repository, c *git.Commit) []term.Cell { 211 | var cells []term.Cell 212 | if refs, ok := r.RefMap[c.Hash]; ok { 213 | if len(refs) <= 0 { 214 | return cells 215 | } 216 | cells = term.Cprint("(", color.FgYellow) 217 | for _, ref := range refs { 218 | switch ref.Type() { 219 | case git.RefTypeHEAD: 220 | cells = append(cells, term.Cprint("HEAD -> ", color.FgCyan, color.Bold)...) 221 | cells = append(cells, term.Cprint(ref.String(), color.FgGreen, color.Bold)...) 222 | cells = append(cells, term.Cprint(", ", color.FgYellow)...) 223 | case git.RefTypeTag: 224 | cells = append(cells, term.Cprint("tag: ", color.FgYellow, color.Bold)...) 225 | cells = append(cells, term.Cprint(ref.String(), color.FgRed, color.Bold)...) 226 | cells = append(cells, term.Cprint(", ", color.FgYellow)...) 227 | case git.RefTypeBranch: 228 | cells = append(cells, term.Cprint(ref.String(), color.FgRed, color.Bold)...) 229 | cells = append(cells, term.Cprint(", ", color.FgYellow)...) 230 | } 231 | } 232 | cells = cells[:len(cells)-2] 233 | cells = append(cells, term.Cprint(")", color.FgYellow)...) 234 | } 235 | return cells 236 | } 237 | -------------------------------------------------------------------------------- /cli/rendering.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/fatih/color" 8 | "github.com/isacikgoz/gitin/git" 9 | "github.com/isacikgoz/gitin/term" 10 | ) 11 | 12 | func renderItem(item interface{}, matches []int, selected bool) [][]term.Cell { 13 | var line []term.Cell 14 | if selected { 15 | line = append(line, term.Cprint("> ", color.FgCyan)...) 16 | } else { 17 | line = append(line, term.Cprint(" ", color.FgWhite)...) 18 | } 19 | switch i := item.(type) { 20 | case *git.StatusEntry: // nolint: typecheck 21 | attr := color.FgRed 22 | if i.Indexed() { 23 | attr = color.FgGreen 24 | } 25 | line = append(line, stautsText(i.StatusEntryString()[:1])...) 26 | line = append(line, highLightedText(matches, attr, i.String())...) 27 | case *git.Commit: 28 | line = append(line, stautsText(i.Hash[:7])...) 29 | line = append(line, highLightedText(matches, color.FgWhite, i.String())...) 30 | case *git.DiffDelta: 31 | line = append(line, stautsText(i.DeltaStatusString()[:1])...) 32 | line = append(line, highLightedText(matches, color.FgWhite, i.String())...) 33 | case *git.Branch: 34 | attr := color.FgWhite 35 | headIndicator := "" 36 | if i.Head { 37 | attr = color.FgGreen 38 | headIndicator = " *" 39 | } else if i.IsRemote() { 40 | attr = color.FgRed 41 | } 42 | line = append(line, highLightedText(matches, attr, i.String()+headIndicator)...) 43 | default: 44 | line = append(line, highLightedText(matches, color.FgWhite, fmt.Sprint(item))...) 45 | } 46 | return [][]term.Cell{line} 47 | } 48 | 49 | func stautsText(text string) []term.Cell { 50 | var cells []term.Cell 51 | if len(text) == 0 { 52 | return cells 53 | } 54 | cells = append(cells, term.Cell{Ch: '['}) 55 | cells = append(cells, term.Cprint(text, color.FgCyan)...) 56 | cells = append(cells, term.Cell{Ch: ']'}) 57 | cells = append(cells, term.Cell{Ch: ' '}) 58 | return cells 59 | } 60 | 61 | func highLightedText(matches []int, c color.Attribute, str string) []term.Cell { 62 | if len(matches) == 0 { 63 | return term.Cprint(str, c) 64 | } 65 | highligted := make([]term.Cell, 0) 66 | for _, r := range str { 67 | highligted = append(highligted, term.Cell{ 68 | Ch: r, 69 | Attr: []color.Attribute{c}, 70 | }) 71 | } 72 | for _, m := range matches { 73 | if m > len(highligted)-1 { 74 | continue 75 | } 76 | highligted[m] = term.Cell{ 77 | Ch: highligted[m].Ch, 78 | Attr: append(highligted[m].Attr, color.Underline), 79 | } 80 | } 81 | return highligted 82 | } 83 | 84 | func branchInfo(b *git.Branch, yours bool) [][]term.Cell { 85 | sal := "This" 86 | if yours { 87 | sal = "Your" 88 | } 89 | var grid [][]term.Cell 90 | if b == nil { 91 | return append(grid, term.Cprint("Unable to load branch info", color.Faint)) 92 | } 93 | if yours && len(b.Name) > 0 { 94 | bName := term.Cprint("On branch ", color.Faint) 95 | bName = append(bName, term.Cprint(b.Name, color.FgYellow)...) 96 | grid = append(grid, bName) 97 | } 98 | if b.Upstream == nil { 99 | return append(grid, term.Cprint(sal+" branch is not tracking a remote branch.", color.Faint)) 100 | } 101 | pl := b.Behind 102 | ps := b.Ahead 103 | if ps == 0 && pl == 0 { 104 | cells := term.Cprint(sal+" branch is up to date with ", color.Faint) 105 | cells = append(cells, term.Cprint(b.Upstream.Name, color.FgCyan)...) 106 | cells = append(cells, term.Cprint(".", color.Faint)...) 107 | grid = append(grid, cells) 108 | } else { 109 | ucs := term.Cprint(b.Upstream.Name, color.FgCyan) 110 | if ps > 0 && pl > 0 { 111 | cells := term.Cprint(sal+" branch and ", color.Faint) 112 | cells = append(cells, ucs...) 113 | cells = append(cells, term.Cprint(" have diverged,", color.Faint)...) 114 | grid = append(grid, cells) 115 | cells = term.Cprint("and have ", color.Faint) 116 | cells = append(cells, term.Cprint(strconv.Itoa(ps), color.FgYellow)...) 117 | cells = append(cells, term.Cprint(" and ", color.Faint)...) 118 | cells = append(cells, term.Cprint(strconv.Itoa(pl), color.FgYellow)...) 119 | cells = append(cells, term.Cprint(" different commits each, respectively.", color.Faint)...) 120 | grid = append(grid, cells) 121 | grid = append(grid, term.Cprint("(\"pull\" to merge the remote branch into yours)", color.Faint)) 122 | } else if pl > 0 && ps == 0 { 123 | cells := term.Cprint(sal+" branch is behind ", color.Faint) 124 | cells = append(cells, ucs...) 125 | cells = append(cells, term.Cprint(" by ", color.Faint)...) 126 | cells = append(cells, term.Cprint(strconv.Itoa(pl), color.FgYellow)...) 127 | cells = append(cells, term.Cprint(" commit(s).", color.Faint)...) 128 | grid = append(grid, cells) 129 | grid = append(grid, term.Cprint("(\"pull\" to update your local branch)", color.Faint)) 130 | } else if ps > 0 && pl == 0 { 131 | cells := term.Cprint(sal+" branch is ahead of ", color.Faint) 132 | cells = append(cells, ucs...) 133 | cells = append(cells, term.Cprint(" by ", color.Faint)...) 134 | cells = append(cells, term.Cprint(strconv.Itoa(ps), color.FgYellow)...) 135 | cells = append(cells, term.Cprint(" commit(s).", color.Faint)...) 136 | grid = append(grid, cells) 137 | grid = append(grid, term.Cprint("(\"push\" to publish your local commit(s))", color.Faint)) 138 | } 139 | } 140 | return grid 141 | } 142 | 143 | func workingTreeClean(b *git.Branch) [][]term.Cell { 144 | var grid [][]term.Cell 145 | grid = branchInfo(b, true) 146 | grid = append(grid, term.Cprint("Nothing to commit, working tree clean", color.Faint)) 147 | return grid 148 | } 149 | -------------------------------------------------------------------------------- /cli/status.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "os/exec" 8 | 9 | "github.com/isacikgoz/gia/editor" 10 | "github.com/isacikgoz/gitin/git" 11 | "github.com/isacikgoz/gitin/prompt" 12 | "github.com/isacikgoz/gitin/term" 13 | "github.com/waigani/diffparser" 14 | ) 15 | 16 | // status holds the repository struct and the prompt pointer. 17 | type status struct { 18 | repository *git.Repository 19 | prompt *prompt.Prompt 20 | } 21 | 22 | // StatusPrompt configures a prompt to serve as work-dir explorer prompt 23 | func StatusPrompt(r *git.Repository, opts *prompt.Options) (*prompt.Prompt, error) { 24 | st, err := r.LoadStatus() 25 | if err != nil { 26 | return nil, fmt.Errorf("could not load status: %v", err) 27 | } 28 | if len(st.Entities) == 0 { 29 | writer := term.NewBufferedWriter(os.Stdout) 30 | for _, line := range workingTreeClean(r.Head) { 31 | writer.WriteCells(line) 32 | } 33 | writer.Flush() 34 | os.Exit(0) 35 | } 36 | list, err := prompt.NewList(st.Entities, opts.LineSize) 37 | if err != nil { 38 | return nil, fmt.Errorf("could not create list: %v", err) 39 | } 40 | 41 | s := &status{repository: r} 42 | 43 | s.prompt = prompt.Create("Files", opts, list, 44 | prompt.WithSelectionHandler(s.onSelect), 45 | prompt.WithItemRenderer(renderItem), 46 | prompt.WithInformation(s.info), 47 | ) 48 | if err := s.defineKeybindings(); err != nil { 49 | return nil, err 50 | } 51 | 52 | return s.prompt, nil 53 | } 54 | 55 | // return err to terminate 56 | func (s *status) onSelect(item interface{}) error { 57 | entry := item.(*git.StatusEntry) 58 | if err := popGitCommand(s.repository, fileStatArgs(entry)); err != nil { 59 | return nil // intentionally ignore errors here 60 | } 61 | return nil 62 | } 63 | 64 | func (s *status) info(item interface{}) [][]term.Cell { 65 | b := s.repository.Head 66 | return branchInfo(b, true) 67 | } 68 | 69 | func (s *status) defineKeybindings() error { 70 | keybindings := []*prompt.KeyBinding{ 71 | &prompt.KeyBinding{ 72 | Key: ' ', 73 | Display: "space", 74 | Desc: "add/reset entry", 75 | Handler: s.addResetEntry, 76 | }, 77 | &prompt.KeyBinding{ 78 | Key: 'p', 79 | Display: "p", 80 | Desc: "hunk stage entry", 81 | Handler: s.hunkStageEntry, 82 | }, 83 | &prompt.KeyBinding{ 84 | Key: 'c', 85 | Display: "c", 86 | Desc: "commit", 87 | Handler: s.commit, 88 | }, 89 | &prompt.KeyBinding{ 90 | Key: 'm', 91 | Display: "m", 92 | Desc: "amend", 93 | Handler: s.amend, 94 | }, 95 | &prompt.KeyBinding{ 96 | Key: 'a', 97 | Display: "a", 98 | Desc: "add all", 99 | Handler: s.addAllEntries, 100 | }, 101 | &prompt.KeyBinding{ 102 | Key: 'r', 103 | Display: "r", 104 | Desc: "reset all", 105 | Handler: s.resetAllEntries, 106 | }, 107 | &prompt.KeyBinding{ 108 | Key: '!', 109 | Display: "!", 110 | Desc: "discard changes", 111 | Handler: s.discardEntry, 112 | }, 113 | &prompt.KeyBinding{ 114 | Key: 'q', 115 | Display: "q", 116 | Desc: "quit", 117 | Handler: s.quit, 118 | }, 119 | } 120 | for _, kb := range keybindings { 121 | if err := s.prompt.AddKeyBinding(kb); err != nil { 122 | return err 123 | } 124 | } 125 | return nil 126 | } 127 | 128 | func (s *status) addResetEntry(item interface{}) error { 129 | entry := item.(*git.StatusEntry) 130 | args := []string{"add", "--", entry.String()} 131 | if entry.Indexed() { 132 | args = []string{"reset", "HEAD", "--", entry.String()} 133 | } 134 | return s.runCommandWithArgs(args) 135 | } 136 | 137 | func (s *status) hunkStageEntry(item interface{}) error { 138 | entry := item.(*git.StatusEntry) 139 | file, err := generateDiffFile(s.repository, entry) 140 | if err == nil { 141 | editor, err := editor.NewEditor(file) 142 | if err != nil { 143 | return err 144 | } 145 | patches, err := editor.Run() 146 | if err != nil { 147 | return err 148 | } 149 | for _, patch := range patches { 150 | if err := applyPatchCmd(s.repository, entry, patch); err != nil { 151 | return err 152 | } 153 | } 154 | } 155 | return s.reloadStatus() 156 | } 157 | 158 | func (s *status) commit(item interface{}) error { 159 | s.bareCommit("--edit") // why ignore err? simply to return status screen 160 | return nil 161 | } 162 | 163 | func (s *status) amend(item interface{}) error { 164 | s.bareCommit("--amend") 165 | return nil 166 | } 167 | 168 | func (s *status) bareCommit(arg string) error { 169 | args := []string{"commit", arg, "--quiet"} 170 | err := popGitCommand(s.repository, args) 171 | if err != nil { 172 | return err 173 | } 174 | s.repository.LoadHead() 175 | args, err = lastCommitArgs(s.repository) 176 | if err != nil { 177 | return err 178 | } 179 | if err := popGitCommand(s.repository, args); err != nil { 180 | return fmt.Errorf("failed to commit: %v", err) 181 | } 182 | return s.reloadStatus() 183 | } 184 | 185 | func (s *status) addAllEntries(item interface{}) error { 186 | args := []string{"add", "."} 187 | return s.runCommandWithArgs(args) 188 | } 189 | 190 | func (s *status) resetAllEntries(item interface{}) error { 191 | args := []string{"reset", "--mixed"} 192 | return s.runCommandWithArgs(args) 193 | } 194 | 195 | func (s *status) discardEntry(item interface{}) error { 196 | entry := item.(*git.StatusEntry) 197 | var args []string 198 | if entry.EntryType == git.StatusEntryTypeUntracked { 199 | args = []string{"clean", "--force", entry.String()} 200 | } else { 201 | args = []string{"checkout", "--", entry.String()} 202 | } 203 | return s.runCommandWithArgs(args) 204 | } 205 | 206 | func (s *status) quit(item interface{}) error { 207 | s.prompt.Stop() 208 | return nil 209 | } 210 | 211 | func (s *status) runCommandWithArgs(args []string) error { 212 | cmd := exec.Command("git", args...) 213 | cmd.Dir = s.repository.Path() 214 | if err := cmd.Run(); err != nil { 215 | return nil //ignore command errors for now 216 | } 217 | return s.reloadStatus() 218 | } 219 | 220 | // reloads the list 221 | func (s *status) reloadStatus() error { 222 | s.repository.LoadHead() 223 | status, err := s.repository.LoadStatus() 224 | if err != nil { 225 | return err 226 | } 227 | if len(status.Entities) == 0 { 228 | // this is the case when the working tree is cleaned at runtime 229 | s.prompt.Stop() 230 | s.prompt.SetExitMsg(workingTreeClean(s.repository.Head)) 231 | return nil 232 | } 233 | state := s.prompt.State() 234 | list, err := prompt.NewList(status.Entities, state.ListSize) 235 | if err != nil { 236 | return err 237 | } 238 | state.List = list 239 | s.prompt.SetState(state) 240 | return nil 241 | } 242 | 243 | // fileStatArgs returns git command args for getting diff 244 | func fileStatArgs(e *git.StatusEntry) []string { 245 | var args []string 246 | if e.Indexed() { 247 | args = []string{"diff", "--cached", e.String()} 248 | } else if e.EntryType == git.StatusEntryTypeUntracked { 249 | args = []string{"diff", "--no-index", "/dev/null", e.String()} 250 | } else { 251 | args = []string{"diff", "--", e.String()} 252 | } 253 | return args 254 | } 255 | 256 | // lastCommitArgs returns the args for show stat 257 | func lastCommitArgs(r *git.Repository) ([]string, error) { 258 | r.LoadStatus() 259 | head := r.Head 260 | if head == nil { 261 | return nil, fmt.Errorf("can't get HEAD") 262 | } 263 | hash := string(head.Target().Hash) 264 | args := []string{"show", "--stat", hash} 265 | return args, nil 266 | } 267 | 268 | func generateDiffFile(r *git.Repository, entry *git.StatusEntry) (*diffparser.DiffFile, error) { 269 | args := fileStatArgs(entry) 270 | cmd := exec.Command("git", args...) 271 | cmd.Dir = r.Path() 272 | out, err := cmd.CombinedOutput() 273 | if err != nil { 274 | return nil, err 275 | } 276 | diff, err := diffparser.Parse(string(out)) 277 | if err != nil { 278 | return nil, err 279 | } 280 | return diff.Files[0], nil 281 | } 282 | 283 | func applyPatchCmd(r *git.Repository, entry *git.StatusEntry, patch string) error { 284 | mode := []string{"apply", "--cached"} 285 | if entry.Indexed() { 286 | mode = []string{"apply", "--cached", "--reverse"} 287 | } 288 | cmd := exec.Command("git", mode...) 289 | cmd.Dir = r.Path() 290 | stdin, err := cmd.StdinPipe() 291 | if err != nil { 292 | return err 293 | } 294 | go func() { 295 | defer stdin.Close() 296 | io.WriteString(stdin, patch+"\n") 297 | }() 298 | if err := cmd.Run(); err != nil { 299 | return err 300 | } 301 | return nil 302 | } 303 | -------------------------------------------------------------------------------- /cmd/gitin/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/isacikgoz/gitin/cli" 9 | "github.com/isacikgoz/gitin/git" 10 | "github.com/isacikgoz/gitin/prompt" 11 | 12 | env "github.com/kelseyhightower/envconfig" 13 | pin "gopkg.in/alecthomas/kingpin.v2" 14 | ) 15 | 16 | func main() { 17 | mode := evalArgs() 18 | pwd, _ := os.Getwd() 19 | 20 | r, err := git.Open(pwd) 21 | exitIfError(err) 22 | 23 | var o prompt.Options 24 | err = env.Process("gitin", &o) 25 | exitIfError(err) 26 | 27 | var p *prompt.Prompt 28 | 29 | // cli package is for responsible to create and configure a prompt 30 | switch mode { 31 | case "status": 32 | p, err = cli.StatusPrompt(r, &o) 33 | case "log": 34 | p, err = cli.LogPrompt(r, &o) 35 | case "branch": 36 | p, err = cli.BranchPrompt(r, &o) 37 | default: 38 | return 39 | } 40 | 41 | exitIfError(err) 42 | ctx := context.Background() 43 | exitIfError(p.Run(ctx)) 44 | } 45 | 46 | func exitIfError(err error) { 47 | if err != nil { 48 | fmt.Fprintf(os.Stderr, "%v\n", err) 49 | os.Exit(1) 50 | } 51 | } 52 | 53 | // define the program commands and args 54 | func evalArgs() string { 55 | pin.Command("log", "Show commit logs.") 56 | pin.Command("status", "Show working-tree status. Also stage and commit changes.") 57 | pin.Command("branch", "Show list of branches.") 58 | 59 | pin.Version("gitin version 0.3.0") 60 | 61 | pin.UsageTemplate(pin.DefaultUsageTemplate + additionalHelp() + "\n") 62 | pin.CommandLine.HelpFlag.Short('h') 63 | pin.CommandLine.VersionFlag.Short('v') 64 | 65 | return pin.Parse() 66 | } 67 | 68 | func additionalHelp() string { 69 | return `Environment Variables: 70 | 71 | GITIN_LINESIZE= 72 | GITIN_STARTINSEARCH= 73 | GITIN_DISABLECOLOR= 74 | 75 | Press ? for controls while application is running.` 76 | } 77 | -------------------------------------------------------------------------------- /git/branch.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "strings" 5 | 6 | lib "github.com/libgit2/git2go/v33" 7 | ) 8 | 9 | // Branch is a wrapper of lib.Branch object 10 | type Branch struct { 11 | refType RefType 12 | essence *lib.Branch 13 | owner *Repository 14 | target *Commit 15 | 16 | Name string 17 | FullName string 18 | Hash string 19 | isRemote bool 20 | Head bool 21 | Ahead int 22 | Behind int 23 | Upstream *Branch 24 | } 25 | 26 | // Branches loads branches with the lib's branch iterator 27 | // loads both remote and local branches 28 | func (r *Repository) Branches() ([]*Branch, error) { 29 | branchIter, err := r.essence.NewBranchIterator(lib.BranchAll) 30 | if err != nil { 31 | return nil, err 32 | } 33 | defer branchIter.Free() 34 | buffer := make([]*Branch, 0) 35 | 36 | err = branchIter.ForEach(func(branch *lib.Branch, branchType lib.BranchType) error { 37 | b, err := unpackRawBranch(r.essence, branch) 38 | if err != nil { 39 | return err 40 | } 41 | obj, err := r.essence.RevparseSingle(b.Hash) 42 | if err == nil && obj != nil { 43 | if commit, _ := obj.AsCommit(); commit != nil { 44 | b.target = unpackRawCommit(r, commit) 45 | } 46 | } 47 | // add to refmap 48 | if _, ok := r.RefMap[b.Hash]; !ok { 49 | r.RefMap[b.Hash] = make([]Ref, 0) 50 | } 51 | refs := r.RefMap[b.Hash] 52 | refs = append(refs, b) 53 | r.RefMap[b.Hash] = refs 54 | 55 | buffer = append(buffer, b) 56 | return nil 57 | }) 58 | 59 | return buffer, err 60 | } 61 | 62 | func unpackRawBranch(r *lib.Repository, branch *lib.Branch) (*Branch, error) { 63 | name, err := branch.Name() 64 | if err != nil { 65 | return nil, err 66 | } 67 | fullname := branch.Reference.Name() 68 | 69 | rawOid := branch.Target() 70 | 71 | if rawOid == nil { 72 | ref, err := branch.Resolve() 73 | if err != nil { 74 | return nil, err 75 | } 76 | rawOid = ref.Target() 77 | } 78 | var ahead, behind int 79 | hash := rawOid.String() 80 | isRemote := branch.IsRemote() 81 | isHead, _ := branch.IsHead() 82 | 83 | var upstream *Branch 84 | if !isRemote { 85 | us, err := branch.Upstream() 86 | if err != nil || us == nil { 87 | // upstream not found 88 | } else { 89 | var err error 90 | ahead, behind, err = r.AheadBehind(branch.Reference.Target(), us.Target()) 91 | if err != nil { 92 | ahead = 0 93 | behind = 0 94 | } 95 | upstream = &Branch{ 96 | Name: strings.Replace(us.Name(), "refs/remotes/", "", 1), 97 | FullName: us.Name(), 98 | Hash: us.Target().String(), 99 | isRemote: true, 100 | essence: us.Branch(), 101 | } 102 | } 103 | } 104 | 105 | b := &Branch{ 106 | Name: name, 107 | refType: RefTypeBranch, 108 | essence: branch, 109 | FullName: fullname, 110 | Hash: hash, 111 | isRemote: isRemote, 112 | Head: isHead, 113 | Upstream: upstream, 114 | Ahead: ahead, 115 | Behind: behind, 116 | } 117 | if isHead, _ := branch.IsHead(); isHead { 118 | b.refType = RefTypeHEAD 119 | } 120 | return b, nil 121 | } 122 | 123 | // Type is the reference type of this ref 124 | func (b *Branch) Type() RefType { 125 | return b.refType 126 | } 127 | 128 | // Target is the hash of targeted commit 129 | func (b *Branch) Target() *Commit { 130 | return b.target 131 | } 132 | 133 | func (b *Branch) String() string { 134 | return b.Name 135 | } 136 | 137 | // IsRemote returns false if it is a local branch 138 | func (b *Branch) IsRemote() bool { 139 | return b.isRemote 140 | } 141 | -------------------------------------------------------------------------------- /git/commit.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | lib "github.com/libgit2/git2go/v33" 9 | ) 10 | 11 | // Commit is the wrapper of actual lib.Commit object 12 | type Commit struct { 13 | essence *lib.Commit 14 | owner *Repository 15 | 16 | Author *Signature 17 | Message string 18 | Summary string 19 | Hash string 20 | } 21 | 22 | // Signature is the person who signs a commit 23 | type Signature struct { 24 | Name string 25 | Email string 26 | When time.Time 27 | } 28 | 29 | func (s *Signature) toNewLibSignature() *lib.Signature { 30 | return &lib.Signature{ 31 | Name: s.Name, 32 | Email: s.Email, 33 | When: s.When, 34 | } 35 | } 36 | 37 | // Commits returns all of the commits of the repository 38 | func (r *Repository) Commits() ([]*Commit, error) { 39 | head, err := r.essence.Head() 40 | if err != nil { 41 | return nil, err 42 | } 43 | walk, err := r.essence.Walk() 44 | if err != nil { 45 | return nil, err 46 | } 47 | if err := walk.Push(head.Target()); err != nil { 48 | return nil, err 49 | } 50 | buffer := make([]*Commit, 0) 51 | defer walk.Free() 52 | err = walk.Iterate(func(commit *lib.Commit) bool { 53 | 54 | c := unpackRawCommit(r, commit) 55 | 56 | buffer = append(buffer, c) 57 | return true 58 | }) 59 | return buffer, err 60 | } 61 | 62 | // Commits returns commits as channel with given size 63 | func (r *Repository) CommitsChan(size int) (chan *Commit, error) { 64 | head, err := r.essence.Head() 65 | if err != nil { 66 | return nil, err 67 | } 68 | walk, err := r.essence.Walk() 69 | if err != nil { 70 | return nil, err 71 | } 72 | if err := walk.Push(head.Target()); err != nil { 73 | return nil, err 74 | } 75 | buffer := make(chan *Commit, size) 76 | 77 | go func() { 78 | defer walk.Free() 79 | defer close(buffer) 80 | err = walk.Iterate(func(commit *lib.Commit) bool { 81 | buffer <- unpackRawCommit(r, commit) 82 | 83 | return true 84 | }) 85 | if err != nil { 86 | panic(err) 87 | } 88 | }() 89 | 90 | return buffer, nil 91 | } 92 | 93 | func unpackRawCommit(repo *Repository, raw *lib.Commit) *Commit { 94 | oid := raw.AsObject().Id() 95 | 96 | hash := oid.String() 97 | author := &Signature{ 98 | Name: raw.Author().Name, 99 | Email: raw.Author().Email, 100 | When: raw.Author().When, 101 | } 102 | sum := raw.Summary() 103 | msg := raw.Message() 104 | 105 | c := &Commit{ 106 | essence: raw, 107 | owner: repo, 108 | Hash: hash, 109 | Author: author, 110 | Message: msg, 111 | Summary: sum, 112 | } 113 | return c 114 | } 115 | 116 | // Commit adds a new commit onject to repository 117 | // warning: this function does not check if the changes are indexed 118 | func (r *Repository) Commit(message string, author ...*Signature) (*Commit, error) { 119 | repo := r.essence 120 | head, err := repo.Head() 121 | if err != nil { 122 | return nil, err 123 | } 124 | defer head.Free() 125 | parent, err := repo.LookupCommit(head.Target()) 126 | if err != nil { 127 | return nil, err 128 | } 129 | defer parent.Free() 130 | index, err := repo.Index() 131 | if err != nil { 132 | return nil, err 133 | } 134 | defer index.Free() 135 | treeid, err := index.WriteTree() 136 | if err != nil { 137 | return nil, err 138 | } 139 | tree, err := repo.LookupTree(treeid) 140 | if err != nil { 141 | return nil, err 142 | } 143 | defer tree.Free() 144 | oid, err := repo.CreateCommit("HEAD", author[0].toNewLibSignature(), author[0].toNewLibSignature(), message, tree, parent) 145 | if err != nil { 146 | return nil, err 147 | } 148 | commit, err := repo.LookupCommit(oid) 149 | if err != nil { 150 | return nil, err 151 | } 152 | return unpackRawCommit(r, commit), nil 153 | } 154 | 155 | func (c *Commit) String() string { 156 | return c.Summary 157 | } 158 | 159 | // Amend updates the commit and returns NEW commit pointer 160 | func (c *Commit) Amend(message string, author ...*Signature) (*Commit, error) { 161 | repo := c.owner.essence 162 | index, err := repo.Index() 163 | if err != nil { 164 | return nil, err 165 | } 166 | defer index.Free() 167 | treeid, err := index.WriteTree() 168 | if err != nil { 169 | return nil, err 170 | } 171 | tree, err := repo.LookupTree(treeid) 172 | if err != nil { 173 | return nil, err 174 | } 175 | defer tree.Free() 176 | oid, err := c.essence.Amend("HEAD", author[0].toNewLibSignature(), author[0].toNewLibSignature(), message, tree) 177 | if err != nil { 178 | return nil, err 179 | } 180 | commit, err := repo.LookupCommit(oid) 181 | if err != nil { 182 | return nil, err 183 | } 184 | return &Commit{ 185 | essence: commit, 186 | owner: c.owner, 187 | }, nil 188 | } 189 | 190 | // Diff has similar behavior to "git diff " 191 | func (c *Commit) Diff() (*Diff, error) { 192 | // if c.essence.ParentCount() > 1 { 193 | // return nil, errors.New("commit has multiple parents") 194 | // } 195 | 196 | cTree, err := c.essence.Tree() 197 | if err != nil { 198 | return nil, err 199 | } 200 | defer cTree.Free() 201 | var pTree *lib.Tree 202 | if c.essence.ParentCount() > 0 { 203 | if pTree, err = c.essence.Parent(0).Tree(); err != nil { 204 | return nil, err 205 | } 206 | defer pTree.Free() 207 | } 208 | 209 | opt, err := lib.DefaultDiffOptions() 210 | if err != nil { 211 | return nil, err 212 | } 213 | 214 | diff, err := c.owner.essence.DiffTreeToTree(pTree, cTree, &opt) 215 | if err != nil { 216 | return nil, err 217 | } 218 | defer diff.Free() 219 | 220 | stats, err := diff.Stats() 221 | if err != nil { 222 | return nil, err 223 | } 224 | 225 | statsText, err := stats.String(lib.DiffStatsFull, 80) 226 | if err != nil { 227 | return nil, err 228 | } 229 | ddeltas := make([]*DiffDelta, 0) 230 | patchs := make([]string, 0) 231 | deltas, err := diff.NumDeltas() 232 | if err != nil { 233 | return nil, err 234 | } 235 | 236 | var patch *lib.Patch 237 | var patchtext string 238 | 239 | for i := 0; i < deltas; i++ { 240 | if patch, err = diff.Patch(i); err != nil { 241 | continue 242 | } 243 | var dd lib.DiffDelta 244 | if dd, err = diff.GetDelta(i); err != nil { 245 | continue 246 | } 247 | d := &DiffDelta{ 248 | Status: DeltaStatus(dd.Status), 249 | NewFile: &DiffFile{ 250 | Path: dd.NewFile.Path, 251 | Hash: dd.NewFile.Oid.String(), 252 | }, 253 | OldFile: &DiffFile{ 254 | Path: dd.OldFile.Path, 255 | Hash: dd.OldFile.Oid.String(), 256 | }, 257 | Commit: c, 258 | } 259 | 260 | if patchtext, err = patch.String(); err != nil { 261 | continue 262 | } 263 | d.Patch = patchtext 264 | 265 | ddeltas = append(ddeltas, d) 266 | patchs = append(patchs, patchtext) 267 | 268 | if err := patch.Free(); err != nil { 269 | return nil, err 270 | } 271 | } 272 | 273 | d := &Diff{ 274 | deltas: ddeltas, 275 | stats: strings.Split(statsText, "\n"), 276 | patchs: patchs, 277 | } 278 | return d, nil 279 | } 280 | 281 | // ParentID returns the commits parent hash. 282 | func (c *Commit) ParentID() (string, error) { 283 | if c.essence.Parent(0) == nil { 284 | return "", fmt.Errorf("%s", "commit does not have parents") 285 | } 286 | return c.essence.Parent(0).AsObject().Id().String(), nil 287 | } 288 | -------------------------------------------------------------------------------- /git/errors.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | // Error is the errors from the git package 4 | type Error string 5 | 6 | func (e Error) Error() string { 7 | return string(e) 8 | } 9 | 10 | var ( 11 | // ErrAuthenticationRequired as the name implies 12 | ErrAuthenticationRequired Error = "authentication required" 13 | // ErrAuthenticationType means that given credentials cannot be used for given repository url 14 | ErrAuthenticationType Error = "authentication method is not valid" 15 | // ErrClone is a generic clone error 16 | ErrClone Error = "cannot clone repo" 17 | // ErrCannotOpenRepo is returned when the repo couldn't be loaded from filesystem 18 | ErrCannotOpenRepo Error = "cannot load repository" 19 | // ErrCreateCallbackFail is returned when an error occurred while creating callbacks 20 | ErrCreateCallbackFail Error = "cannot create default callbacks" 21 | // ErrNoRemoteName if the remote name is empty while fetching 22 | ErrNoRemoteName Error = "remote name not specified" 23 | // ErrNotValidRemoteName is returned if the given remote name is not found 24 | ErrNotValidRemoteName Error = "not a valid remote name" 25 | // ErrAlreadyUpToDate if the repo is up-to-date 26 | ErrAlreadyUpToDate Error = "already up-to-date" 27 | // ErrFastForwardOnly if the merge can be made by fast-forward 28 | ErrFastForwardOnly Error = "fast-forward only" 29 | // ErrBranchNotFound is returned when the given ref can't found 30 | ErrBranchNotFound Error = "cannot locate remote-tracking branch" 31 | // ErrEntryNotIndexed is returned when the entry is not indexed 32 | ErrEntryNotIndexed Error = "entry is not indexed" 33 | ) 34 | -------------------------------------------------------------------------------- /git/repository.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "errors" 5 | "path/filepath" 6 | 7 | lib "github.com/libgit2/git2go/v33" 8 | ) 9 | 10 | // Repository is the wrapper and main interface to git repository 11 | type Repository struct { 12 | essence *lib.Repository 13 | path string 14 | 15 | RefMap map[string][]Ref 16 | Head *Branch 17 | } 18 | 19 | // RefType defines the ref types 20 | type RefType uint8 21 | 22 | // These types are used for mapping references 23 | const ( 24 | RefTypeTag RefType = iota 25 | RefTypeBranch 26 | RefTypeHEAD 27 | ) 28 | 29 | // Ref is the wrapper of lib.Ref 30 | type Ref interface { 31 | Type() RefType 32 | Target() *Commit 33 | String() string 34 | } 35 | 36 | // Open load the repository from the filesystem 37 | func Open(path string) (*Repository, error) { 38 | repo, realpath, err := initRepoFromPath(path) 39 | if err != nil { 40 | return nil, ErrCannotOpenRepo 41 | } 42 | r := &Repository{ 43 | path: realpath, 44 | essence: repo, 45 | } 46 | r.RefMap = make(map[string][]Ref) 47 | r.LoadHead() 48 | return r, nil 49 | } 50 | 51 | func initRepoFromPath(path string) (*lib.Repository, string, error) { 52 | walk := path 53 | for { 54 | r, err := lib.OpenRepository(walk) 55 | if err == nil { 56 | return r, walk, err 57 | } 58 | walk = filepath.Dir(walk) 59 | if walk == "/" { 60 | break 61 | } 62 | } 63 | return nil, walk, errors.New("cannot load a git repository from " + path) 64 | } 65 | 66 | // LoadHead can be used to refresh HEAD ref 67 | func (r *Repository) LoadHead() error { 68 | head, err := r.essence.Head() 69 | if err != nil { 70 | return err 71 | } 72 | branch, err := unpackRawBranch(r.essence, head.Branch()) 73 | if err != nil { 74 | return err 75 | } 76 | obj, err := r.essence.RevparseSingle(branch.Hash) 77 | if err == nil && obj != nil { 78 | if commit, _ := obj.AsCommit(); commit != nil { 79 | branch.target = unpackRawCommit(r, commit) 80 | } 81 | } 82 | if err != nil { 83 | // a warning here 84 | } 85 | r.Head = branch 86 | return nil 87 | } 88 | 89 | // Path returns the filesystem location of the repository 90 | func (r *Repository) Path() string { 91 | return r.path 92 | } 93 | -------------------------------------------------------------------------------- /git/repository_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestOpen(t *testing.T) { 9 | wd, _ := os.Getwd() 10 | var tests = []struct { 11 | input string 12 | err error 13 | }{ 14 | {"/tmp", ErrCannotOpenRepo}, 15 | {"/", ErrCannotOpenRepo}, 16 | {wd, nil}, 17 | } 18 | for _, test := range tests { 19 | if _, err := Open(test.input); err != test.err { 20 | t.Errorf("input: %s\n error: %s", test.input, err.Error()) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /git/status.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | lib "github.com/libgit2/git2go/v33" 5 | ) 6 | 7 | // State is the current state of the repository 8 | type State int 9 | 10 | // The different states for a repo 11 | const ( 12 | StateUnknown State = iota 13 | StateNone 14 | StateMerge 15 | StateRevert 16 | StateCherrypick 17 | StateBisect 18 | StateRebase 19 | StateRebaseInteractive 20 | StateRebaseMerge 21 | StateApplyMailbox 22 | StateApplyMailboxOrRebase 23 | ) 24 | 25 | // DeltaStatus ondicates a files status in a diff 26 | type DeltaStatus int 27 | 28 | // Delta status of a file e.g. on a commit 29 | const ( 30 | DeltaUnmodified DeltaStatus = iota 31 | DeltaAdded 32 | DeltaDeleted 33 | DeltaModified 34 | DeltaRenamed 35 | DeltaCopied 36 | DeltaIgnored 37 | DeltaUntracked 38 | DeltaTypeChange 39 | DeltaUnreadable 40 | DeltaConflicted 41 | ) 42 | 43 | // IndexType describes the different stages a status entry can be in 44 | type IndexType int 45 | 46 | // The different status stages 47 | const ( 48 | IndexTypeStaged IndexType = iota 49 | IndexTypeUnstaged 50 | IndexTypeUntracked 51 | IndexTypeConflicted 52 | ) 53 | 54 | // StatusEntryType describes the type of change a status entry has undergone 55 | type StatusEntryType int 56 | 57 | // The set of supported StatusEntryTypes 58 | const ( 59 | StatusEntryTypeNew StatusEntryType = iota 60 | StatusEntryTypeModified 61 | StatusEntryTypeDeleted 62 | StatusEntryTypeRenamed 63 | StatusEntryTypeUntracked 64 | StatusEntryTypeTypeChange 65 | StatusEntryTypeConflicted 66 | ) 67 | 68 | var indexTypeMap = map[lib.Status]IndexType{ 69 | lib.StatusIndexNew | lib.StatusIndexModified | lib.StatusIndexDeleted | lib.StatusIndexRenamed | lib.StatusIndexTypeChange: IndexTypeStaged, 70 | lib.StatusWtModified | lib.StatusWtDeleted | lib.StatusWtTypeChange | lib.StatusWtRenamed: IndexTypeUnstaged, 71 | lib.StatusWtNew: IndexTypeUntracked, 72 | lib.StatusConflicted: IndexTypeConflicted, 73 | } 74 | 75 | var statusEntryTypeMap = map[lib.Status]StatusEntryType{ 76 | lib.StatusIndexNew: StatusEntryTypeNew, 77 | lib.StatusIndexModified: StatusEntryTypeModified, 78 | lib.StatusWtModified: StatusEntryTypeModified, 79 | lib.StatusIndexDeleted: StatusEntryTypeDeleted, 80 | lib.StatusWtDeleted: StatusEntryTypeDeleted, 81 | lib.StatusIndexRenamed: StatusEntryTypeRenamed, 82 | lib.StatusWtRenamed: StatusEntryTypeRenamed, 83 | lib.StatusIndexTypeChange: StatusEntryTypeTypeChange, 84 | lib.StatusWtTypeChange: StatusEntryTypeTypeChange, 85 | lib.StatusWtNew: StatusEntryTypeUntracked, 86 | lib.StatusConflicted: StatusEntryTypeConflicted, 87 | } 88 | 89 | // StatusEntry contains data for a single status entry 90 | type StatusEntry struct { 91 | index IndexType 92 | EntryType StatusEntryType 93 | diffDelta *DiffDelta 94 | } 95 | 96 | // Status contains all git status data 97 | type Status struct { 98 | State State 99 | Entities []*StatusEntry 100 | } 101 | 102 | // Diff is the wrapper for a diff content acquired from repo 103 | type Diff struct { 104 | deltas []*DiffDelta 105 | stats []string 106 | patchs []string 107 | } 108 | 109 | // Deltas returns the actual changes with file info 110 | func (d *Diff) Deltas() []*DiffDelta { 111 | return d.deltas 112 | } 113 | 114 | // DiffDelta holds delta status, file changes and the actual patchs 115 | type DiffDelta struct { 116 | Status DeltaStatus 117 | OldFile *DiffFile 118 | NewFile *DiffFile 119 | Patch string 120 | Commit *Commit 121 | } 122 | 123 | // DiffFile the file that has been changed 124 | type DiffFile struct { 125 | Path string 126 | Hash string 127 | } 128 | 129 | func (d *DiffDelta) String() string { 130 | return d.OldFile.Path 131 | } 132 | 133 | // LoadStatus simply emulates a "git status" and returns the result 134 | func (r *Repository) LoadStatus() (*Status, error) { 135 | // this returns err does it matter? 136 | statusOptions := &lib.StatusOptions{ 137 | Show: lib.StatusShowIndexAndWorkdir, 138 | Flags: lib.StatusOptIncludeUntracked, 139 | } 140 | statusList, err := r.essence.StatusList(statusOptions) 141 | if err != nil { 142 | return nil, err 143 | } 144 | defer statusList.Free() 145 | 146 | count, err := statusList.EntryCount() 147 | if err != nil { 148 | return nil, err 149 | } 150 | entities := make([]*StatusEntry, 0) 151 | s := &Status{ 152 | State: State(r.essence.State()), 153 | Entities: entities, 154 | } 155 | for i := 0; i < count; i++ { 156 | statusEntry, err := statusList.ByIndex(i) 157 | if err != nil { 158 | return nil, err 159 | } 160 | if statusEntry.Status <= 0 { 161 | continue 162 | } 163 | s.addToStatus(statusEntry) 164 | } 165 | return s, nil 166 | } 167 | 168 | func (s *Status) addToStatus(raw lib.StatusEntry) { 169 | for rawStatus, indexType := range indexTypeMap { 170 | set := raw.Status & rawStatus 171 | 172 | if set > 0 { 173 | var dd lib.DiffDelta 174 | if indexType == IndexTypeStaged { 175 | dd = raw.HeadToIndex 176 | } else { 177 | dd = raw.IndexToWorkdir 178 | } 179 | d := &DiffDelta{ 180 | Status: DeltaStatus(dd.Status), 181 | NewFile: &DiffFile{ 182 | Path: dd.NewFile.Path, 183 | }, 184 | OldFile: &DiffFile{ 185 | Path: dd.OldFile.Path, 186 | }, 187 | } 188 | e := &StatusEntry{ 189 | index: indexType, 190 | EntryType: statusEntryTypeMap[set], 191 | diffDelta: d, 192 | } 193 | s.Entities = append(s.Entities, e) 194 | } 195 | } 196 | } 197 | 198 | // Indexed true if entry added to index 199 | func (e *StatusEntry) String() string { 200 | return e.diffDelta.OldFile.Path 201 | } 202 | 203 | // Indexed true if entry added to index 204 | func (e *StatusEntry) Indexed() bool { 205 | return e.index == IndexTypeStaged 206 | } 207 | 208 | // StatusEntryString returns entry status in pretty format 209 | func (e *StatusEntry) StatusEntryString() string { 210 | switch e.EntryType { 211 | case StatusEntryTypeNew: 212 | return "Added" 213 | case StatusEntryTypeDeleted: 214 | return "Deleted" 215 | case StatusEntryTypeModified: 216 | return "Modified" 217 | case StatusEntryTypeRenamed: 218 | return "Renamed" 219 | case StatusEntryTypeUntracked: 220 | return "Untracked" 221 | case StatusEntryTypeTypeChange: 222 | return "Type change" 223 | case StatusEntryTypeConflicted: 224 | return "Conflicted" 225 | default: 226 | return "Unknown" 227 | } 228 | } 229 | 230 | // AddToIndex is the wrapper of "git add /path/to/file" command 231 | func (r *Repository) AddToIndex(e *StatusEntry) error { 232 | index, err := r.essence.Index() 233 | if err != nil { 234 | return err 235 | } 236 | if err := index.AddByPath(e.diffDelta.OldFile.Path); err != nil { 237 | return err 238 | } 239 | defer index.Free() 240 | return index.Write() 241 | } 242 | 243 | // RemoveFromIndex is the wrapper of "git reset path/to/file" command 244 | func (r *Repository) RemoveFromIndex(e *StatusEntry) error { 245 | if !e.Indexed() { 246 | return ErrEntryNotIndexed 247 | } 248 | index, err := r.essence.Index() 249 | if err != nil { 250 | return err 251 | } 252 | if err := index.RemoveByPath(e.diffDelta.OldFile.Path); err != nil { 253 | return err 254 | } 255 | defer index.Free() 256 | return index.Write() 257 | } 258 | 259 | // DeltaStatusString retruns delta status as string 260 | func (d *DiffDelta) DeltaStatusString() string { 261 | switch d.Status { 262 | case DeltaUnmodified: 263 | return "Unmodified" 264 | case DeltaAdded: 265 | return "Added" 266 | case DeltaDeleted: 267 | return "Deleted" 268 | case DeltaModified: 269 | return "Modified" 270 | case DeltaRenamed: 271 | return "Renamed" 272 | case DeltaCopied: 273 | return "Copied" 274 | case DeltaIgnored: 275 | return "Ignored" 276 | case DeltaUntracked: 277 | return "Untracked" 278 | case DeltaTypeChange: 279 | return "TypeChange" 280 | case DeltaUnreadable: 281 | return "Unreadable" 282 | case DeltaConflicted: 283 | return "Conflicted" 284 | } 285 | return " " 286 | } 287 | -------------------------------------------------------------------------------- /git/tag.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | // Tag is used to label and mark a specific commit in the history. 4 | type Tag struct { 5 | target *Commit 6 | refType RefType 7 | 8 | Hash string 9 | Shorthand string 10 | Name string 11 | } 12 | 13 | // Tags loads tags from the refs 14 | func (r *Repository) Tags() ([]*Tag, error) { 15 | 16 | iter, err := r.essence.NewReferenceIterator() 17 | if err != nil { 18 | return nil, err 19 | } 20 | defer iter.Free() 21 | buffer := make([]*Tag, 0) 22 | for { 23 | ref, err := iter.Next() 24 | if err != nil || ref == nil { 25 | break 26 | } 27 | 28 | if !ref.IsRemote() && ref.IsTag() { 29 | 30 | t := &Tag{ 31 | Hash: ref.Target().String(), 32 | refType: RefTypeTag, 33 | Name: ref.Name(), 34 | Shorthand: ref.Shorthand(), 35 | } 36 | // add to refmap 37 | if _, ok := r.RefMap[t.Hash]; !ok { 38 | r.RefMap[t.Hash] = make([]Ref, 0) 39 | } 40 | refs := r.RefMap[t.Hash] 41 | refs = append(refs, t) 42 | r.RefMap[t.Hash] = refs 43 | 44 | obj, err := r.essence.RevparseSingle(ref.Target().String()) 45 | if err == nil && obj != nil { 46 | if commit, _ := obj.AsCommit(); commit != nil { 47 | t.target = unpackRawCommit(r, commit) 48 | } 49 | } 50 | buffer = append(buffer, t) 51 | } 52 | } 53 | return buffer, nil 54 | } 55 | 56 | // Type is the reference type of this ref 57 | func (t *Tag) Type() RefType { 58 | return t.refType 59 | } 60 | 61 | // Target is the hash of targeted commit 62 | func (t *Tag) Target() *Commit { 63 | return t.target 64 | } 65 | 66 | func (t *Tag) String() string { 67 | return t.Shorthand 68 | } 69 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/isacikgoz/gitin 2 | 3 | go 1.18 4 | 5 | replace github.com/libgit2/git2go/v33 => ../git2go 6 | 7 | require ( 8 | github.com/fatih/color v1.9.0 9 | github.com/isacikgoz/fuzzy v0.2.0 10 | github.com/isacikgoz/gia v0.2.0 11 | github.com/justincampbell/timeago v0.0.0-20160528003754-027f40306f1d 12 | github.com/kelseyhightower/envconfig v1.4.0 13 | github.com/libgit2/git2go/v33 v33.0.9 14 | github.com/waigani/diffparser v0.0.0-20190828052634-7391f219313d 15 | gopkg.in/alecthomas/kingpin.v2 v2.2.6 16 | ) 17 | 18 | require ( 19 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect 20 | github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d // indirect 21 | github.com/jroimartin/gocui v0.4.0 // indirect 22 | github.com/justincampbell/bigduration v0.0.0-20160531141349-e45bf03c0666 // indirect 23 | github.com/mattn/go-colorable v0.1.4 // indirect 24 | github.com/mattn/go-isatty v0.0.11 // indirect 25 | github.com/mattn/go-runewidth v0.0.4 // indirect 26 | github.com/nsf/termbox-go v0.0.0-20190325093121-288510b9734e // indirect 27 | golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c // indirect 28 | golang.org/x/sys v0.0.0-20201204225414-ed752295db88 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= 2 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 3 | github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d h1:UQZhZ2O0vMHr2cI+DC1Mbh0TJxzA3RcLoMsFw+aXw7E= 4 | github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 9 | github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= 10 | github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= 11 | github.com/isacikgoz/fuzzy v0.2.0 h1:b2AUOLrmR36em9UhkWMkIrEJZFeoPgl9kZzBiktpntU= 12 | github.com/isacikgoz/fuzzy v0.2.0/go.mod h1:VEYn1Gfwj4lMg+FTH603LmQni/zTrhxKv7nTFG+RO8U= 13 | github.com/isacikgoz/gia v0.2.0 h1:fxhF8qtz0KNxSunNMWBWBoQyva267YpSestSvAyUC/s= 14 | github.com/isacikgoz/gia v0.2.0/go.mod h1:pTtCjwM3VmUTZNGXirQ5ixW5NGunvFEO7ah9Boxj6IM= 15 | github.com/jroimartin/gocui v0.4.0 h1:52jnalstgmc25FmtGcWqa0tcbMEWS6RpFLsOIO+I+E8= 16 | github.com/jroimartin/gocui v0.4.0/go.mod h1:7i7bbj99OgFHzo7kB2zPb8pXLqMBSQegY7azfqXMkyY= 17 | github.com/juju/errors v0.0.0-20150916125642-1b5e39b83d18/go.mod h1:W54LbzXuIE0boCoNJfwqpmkKJ1O4TCTZMetAt6jGk7Q= 18 | github.com/juju/loggo v0.0.0-20160511211902-0e0537f18a29/go.mod h1:vgyd7OREkbtVEN/8IXZe5Ooef3LQePvuBm9UWj6ZL8U= 19 | github.com/juju/testing v0.0.0-20160203233110-321edad6b2d1/go.mod h1:63prj8cnj0tU0S9OHjGJn+b1h0ZghCndfnbQolrYTwA= 20 | github.com/juju/utils v0.0.0-20160815113839-bdb77b07e7e3/go.mod h1:6/KLg8Wz/y2KVGWEpkK9vMNGkOnu4k/cqs8Z1fKjTOk= 21 | github.com/justincampbell/bigduration v0.0.0-20160531141349-e45bf03c0666 h1:abLciEiilfMf19Q1TFWDrp9j5z5one60dnnpvc6eabg= 22 | github.com/justincampbell/bigduration v0.0.0-20160531141349-e45bf03c0666/go.mod h1:xqGOmDZzLOG7+q/CgsbXv10g4tgPsbjhmAxyaTJMvis= 23 | github.com/justincampbell/timeago v0.0.0-20160528003754-027f40306f1d h1:qtCcYJK2bebPXEC8Wy+enYxQqmWnT6jlVTHnDGpwvkc= 24 | github.com/justincampbell/timeago v0.0.0-20160528003754-027f40306f1d/go.mod h1:U7FWcK1jzZJnYuSnxP6efX3ZoHbK1CEpD0ThYyGNPNI= 25 | github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= 26 | github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= 27 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 28 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 29 | github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 30 | github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= 31 | github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 32 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 33 | github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM= 34 | github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= 35 | github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y= 36 | github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= 37 | github.com/nsf/termbox-go v0.0.0-20190325093121-288510b9734e h1:Vbib8wJAaMEF9jusI/kMSYMr/LtRzM7+F9MJgt/nH8k= 38 | github.com/nsf/termbox-go v0.0.0-20190325093121-288510b9734e/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= 39 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 40 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 41 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 42 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 43 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 44 | github.com/waigani/diffparser v0.0.0-20190426062500-1f7065f429b5/go.mod h1:CefseIIgCUqtn0B83Lc3+8F2L1V9viokWY2GQlkWGfs= 45 | github.com/waigani/diffparser v0.0.0-20190828052634-7391f219313d h1:xQcF7b7cZLWZG/+7A4G7un1qmEDYHIvId9qxRS1mZMs= 46 | github.com/waigani/diffparser v0.0.0-20190828052634-7391f219313d/go.mod h1:BzSc3WEF8R+lCaP5iGFRxd5kIXy4JKOZAwNe1w0cdc0= 47 | golang.org/x/crypto v0.0.0-20160824173033-351dc6a5bf92/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 48 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 49 | golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 50 | golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c h1:9HhBz5L/UjnK9XLtiZhYAdue5BVKep3PMmS2LuPDt8k= 51 | golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= 52 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 53 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 54 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 55 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 56 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 57 | golang.org/x/sys v0.0.0-20201204225414-ed752295db88 h1:KmZPnMocC93w341XZp26yTJg8Za7lhb2KhkYmixoeso= 58 | golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 59 | golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= 60 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 61 | gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= 62 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 63 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 64 | gopkg.in/check.v1 v1.0.0-20160105164936-4f90aeace3a2/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 65 | gopkg.in/mgo.v2 v2.0.0-20150529124711-01ee097136da/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= 66 | gopkg.in/yaml.v2 v2.0.0-20160715033755-e4d366fc3c79/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= 67 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 68 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 69 | -------------------------------------------------------------------------------- /patch/git2go.v33.patch: -------------------------------------------------------------------------------- 1 | diff --git a/script/build-libgit2.sh b/script/build-libgit2.sh 2 | index 98ff78b..a7d7020 100755 3 | --- a/script/build-libgit2.sh 4 | +++ b/script/build-libgit2.sh 5 | @@ -60,6 +60,13 @@ cmake -DTHREADSAFE=ON \ 6 | -DBUILD_SHARED_LIBS"=${BUILD_SHARED_LIBS}" \ 7 | -DREGEX_BACKEND=builtin \ 8 | -DCMAKE_C_FLAGS=-fPIC \ 9 | + -DUSE_EXT_HTTP_PARSER=OFF \ 10 | + -DUSE_HTTPS=OFF \ 11 | + -DUSE_NSEC=OFF \ 12 | + -DUSE_SSH=OFF \ 13 | + -DCURL=OFF \ 14 | + -DUSE_GSSAPI=OFF \ 15 | + -DUSE_BUNDLED_ZLIB=ON \ 16 | -DCMAKE_BUILD_TYPE="RelWithDebInfo" \ 17 | -DCMAKE_INSTALL_PREFIX="${BUILD_INSTALL_PREFIX}" \ 18 | -DCMAKE_INSTALL_LIBDIR="lib" \ 19 | -------------------------------------------------------------------------------- /prompt/async_list.go: -------------------------------------------------------------------------------- 1 | package prompt 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "reflect" 7 | "sort" 8 | "strings" 9 | "sync" 10 | "sync/atomic" 11 | 12 | "github.com/isacikgoz/fuzzy" 13 | ) 14 | 15 | // AsyncList holds a collection of items that can be displayed with an N number of 16 | // visible items. The list can be moved up, down by one item of time or an 17 | // entire page (ie: visible size). It keeps track of the current selected item. 18 | type AsyncList struct { 19 | itemsChan chan interface{} 20 | items []interface{} 21 | scope []interface{} 22 | buffer []interface{} 23 | matches sync.Map 24 | cursor int // cursor holds the index of the current selected item 25 | size int // size is the number of visible options 26 | start int 27 | find string 28 | mx sync.Mutex 29 | update chan struct{} 30 | ctx *searchContext 31 | } 32 | 33 | type searchContext struct { 34 | ctx context.Context 35 | cancel func() 36 | buffer []fuzzy.Match 37 | progress int32 38 | } 39 | 40 | func newSearchContext(c context.Context) *searchContext { 41 | ctx, cancel := context.WithCancel(c) 42 | return &searchContext{ 43 | ctx: ctx, 44 | cancel: cancel, 45 | buffer: make([]fuzzy.Match, 0), 46 | } 47 | } 48 | 49 | func (c *searchContext) addBuffer(items ...fuzzy.Match) { 50 | c.buffer = append(c.buffer, items...) 51 | } 52 | 53 | func (c *searchContext) clearBuffer() { 54 | c.buffer = make([]fuzzy.Match, 0) 55 | } 56 | 57 | func (c *searchContext) searchInProgress() bool { 58 | return atomic.LoadInt32(&c.progress) != 0 59 | } 60 | 61 | func (c *searchContext) stopSearch() { 62 | if atomic.LoadInt32(&c.progress) == 0 { 63 | return 64 | } 65 | 66 | c.cancel() 67 | c.clearBuffer() 68 | } 69 | 70 | func (c *searchContext) startSearch() bool { 71 | c.ctx, c.cancel = context.WithCancel(context.Background()) 72 | return atomic.CompareAndSwapInt32(&c.progress, 0, 1) 73 | } 74 | 75 | // NewAsyncList creates and initializes a list of searchable items. The items attribute must be a slice type. 76 | func NewAsyncList(items chan interface{}, size int) (*AsyncList, error) { 77 | if size < 1 { 78 | return nil, fmt.Errorf("list size %d must be greater than 0", size) 79 | } 80 | 81 | if items == nil || reflect.TypeOf(items).Kind() != reflect.Chan { 82 | return nil, fmt.Errorf("items %v is not a chan", items) 83 | } 84 | 85 | is := make([]interface{}, 0) 86 | list := &AsyncList{ 87 | size: size, 88 | items: is, 89 | itemsChan: items, 90 | scope: is, 91 | mx: sync.Mutex{}, 92 | update: make(chan struct{}), 93 | buffer: make([]interface{}, 0), 94 | ctx: newSearchContext(context.Background()), 95 | } 96 | 97 | go func() { 98 | var flush int 99 | for val := range items { 100 | list.addBuffer(val) 101 | if flush < 4096 { 102 | flush++ 103 | continue 104 | } 105 | list.flushBuffer() 106 | flush = 0 107 | } 108 | list.flushBuffer() 109 | }() 110 | 111 | return list, nil 112 | } 113 | 114 | func (l *AsyncList) flushBuffer() { 115 | l.mx.Lock() 116 | defer l.mx.Unlock() 117 | 118 | if len(l.buffer) == 0 { 119 | return 120 | } 121 | 122 | l.items = append(l.items, l.buffer...) 123 | l.scope = append(l.scope, l.buffer...) 124 | 125 | // // update for the first iteration of flushing the buffer 126 | // // then, a user input will trigger a re-rendering operation 127 | // if l.update != nil { 128 | l.update <- struct{}{} 129 | // } 130 | // l.update = nil 131 | 132 | l.buffer = make([]interface{}, 0) 133 | } 134 | 135 | func (l *AsyncList) addBuffer(items ...interface{}) { 136 | l.buffer = append(l.buffer, items...) 137 | } 138 | 139 | // Prev moves the visible list back one item. 140 | func (l *AsyncList) Prev() { 141 | if l.cursor > 0 { 142 | l.cursor-- 143 | } 144 | 145 | if l.start > l.cursor { 146 | l.start = l.cursor 147 | } 148 | } 149 | 150 | // Search allows the list to be filtered by a given term. 151 | func (l *AsyncList) Search(term string) { 152 | l.mx.Lock() 153 | defer l.mx.Unlock() 154 | 155 | term = strings.Trim(term, " ") 156 | l.cursor = 0 157 | l.start = 0 158 | l.find = term 159 | l.search(term) 160 | } 161 | 162 | // CancelSearch stops the current search and returns the list to its original order. 163 | func (l *AsyncList) CancelSearch() { 164 | l.cursor = 0 165 | l.start = 0 166 | l.scope = l.items 167 | } 168 | 169 | func (l *AsyncList) flushToScope(ctx *searchContext, fireUpdate bool) { 170 | defer ctx.clearBuffer() 171 | 172 | sort.Stable(fuzzy.Sortable(ctx.buffer)) 173 | for _, match := range ctx.buffer { 174 | item := l.items[match.Index] 175 | l.scope = append(l.scope, item) 176 | l.matches.Store(item, match.MatchedIndexes) 177 | } 178 | if fireUpdate && l.update != nil { 179 | l.update <- struct{}{} 180 | } 181 | } 182 | 183 | func (l *AsyncList) search(term string) { 184 | if len(term) == 0 { 185 | l.scope = l.items 186 | return 187 | } 188 | 189 | l.ctx.stopSearch() 190 | l.matches = sync.Map{} 191 | l.scope = make([]interface{}, 0) 192 | 193 | l.ctx.startSearch() 194 | results := fuzzy.FindFrom(context.Background(), term, interfaceSource(l.items)) 195 | 196 | go func() { 197 | var flush int 198 | var done bool 199 | for result := range results { 200 | if !l.ctx.searchInProgress() { 201 | break 202 | } 203 | l.ctx.addBuffer(result) 204 | 205 | if !done && flush == l.size { 206 | l.flushToScope(l.ctx, true) 207 | done = true 208 | continue 209 | } 210 | 211 | if flush < 16384 { 212 | flush++ 213 | continue 214 | } 215 | 216 | l.flushToScope(l.ctx, false) 217 | flush = 0 218 | } 219 | l.flushToScope(l.ctx, true) 220 | l.ctx.clearBuffer() 221 | }() 222 | } 223 | 224 | // Start returns the current render start position of the list. 225 | func (l *AsyncList) Start() int { 226 | return l.start 227 | } 228 | 229 | // SetStart sets the current scroll position. Values out of bounds will be clamped. 230 | func (l *AsyncList) SetStart(i int) { 231 | if i < 0 { 232 | i = 0 233 | } 234 | if i > l.cursor { 235 | l.start = l.cursor 236 | } else { 237 | l.start = i 238 | } 239 | } 240 | 241 | // SetCursor sets the position of the cursor in the list. Values out of bounds will 242 | // be clamped. 243 | func (l *AsyncList) SetCursor(i int) { 244 | max := len(l.scope) - 1 245 | if i >= max { 246 | i = max 247 | } 248 | if i < 0 { 249 | i = 0 250 | } 251 | l.cursor = i 252 | 253 | if l.start > l.cursor { 254 | l.start = l.cursor 255 | } else if l.start+l.size <= l.cursor { 256 | l.start = l.cursor - l.size + 1 257 | } 258 | } 259 | 260 | // Next moves the visible list forward one item. 261 | func (l *AsyncList) Next() { 262 | max := len(l.scope) - 1 263 | 264 | if l.cursor < max { 265 | l.cursor++ 266 | } 267 | 268 | if l.start+l.size <= l.cursor { 269 | l.start = l.cursor - l.size + 1 270 | } 271 | } 272 | 273 | // PageUp moves the visible list backward by x items. Where x is the size of the 274 | // visible items on the list. 275 | func (l *AsyncList) PageUp() { 276 | start := l.start - l.size 277 | if start < 0 { 278 | l.start = 0 279 | } else { 280 | l.start = start 281 | } 282 | 283 | cursor := l.start 284 | 285 | if cursor < l.cursor { 286 | l.cursor = cursor 287 | } 288 | } 289 | 290 | // PageDown moves the visible list forward by x items. Where x is the size of 291 | // the visible items on the list. 292 | func (l *AsyncList) PageDown() { 293 | start := l.start + l.size 294 | max := len(l.scope) - l.size 295 | 296 | switch { 297 | case len(l.scope) < l.size: 298 | l.start = 0 299 | case start > max: 300 | l.start = max 301 | default: 302 | l.start = start 303 | } 304 | 305 | cursor := l.start 306 | 307 | if cursor == l.cursor { 308 | l.cursor = len(l.scope) - 1 309 | } else if cursor > l.cursor { 310 | l.cursor = cursor 311 | } 312 | } 313 | 314 | // CanPageDown returns whether a list can still PageDown(). 315 | func (l *AsyncList) CanPageDown() bool { 316 | max := len(l.scope) 317 | return l.start+l.size < max 318 | } 319 | 320 | // CanPageUp returns whether a list can still PageUp(). 321 | func (l *AsyncList) CanPageUp() bool { 322 | return l.start > 0 323 | } 324 | 325 | // Index returns the index of the item currently selected inside the searched list. 326 | func (l *AsyncList) Index() int { 327 | if len(l.scope) <= 0 { 328 | return 0 329 | } 330 | selected := l.scope[l.cursor] 331 | 332 | for i, item := range l.items { 333 | if item == selected { 334 | return i 335 | } 336 | } 337 | 338 | return NotFound 339 | } 340 | 341 | // Items returns a slice equal to the size of the list with the current visible 342 | // items and the index of the active item in this list. 343 | func (l *AsyncList) Items() ([]interface{}, int) { 344 | var result []interface{} 345 | max := len(l.scope) 346 | end := l.start + l.size 347 | 348 | if end > max { 349 | end = max 350 | } 351 | 352 | active := NotFound 353 | 354 | for i, j := l.start, 0; i < end; i, j = i+1, j+1 { 355 | if l.cursor == i { 356 | active = j 357 | } 358 | 359 | result = append(result, l.scope[i]) 360 | } 361 | 362 | return result, active 363 | } 364 | 365 | func (l *AsyncList) Size() int { 366 | return l.size 367 | } 368 | 369 | func (l *AsyncList) Cursor() int { 370 | return l.cursor 371 | } 372 | 373 | func (l *AsyncList) Matches(key interface{}) []int { 374 | v, ok := l.matches.Load(key) 375 | if !ok { 376 | return make([]int, 0) 377 | } 378 | return v.([]int) 379 | } 380 | 381 | func (l *AsyncList) Update() chan struct{} { 382 | return l.update 383 | } 384 | -------------------------------------------------------------------------------- /prompt/list.go: -------------------------------------------------------------------------------- 1 | package prompt 2 | 3 | type List interface { 4 | // Next moves the visible list forward one item 5 | Next() 6 | 7 | // Prev moves the visible list back one item. 8 | Prev() 9 | 10 | // PageUp moves the visible list backward by x items. Where x is the size of the 11 | // visible items on the list 12 | PageUp() 13 | 14 | // PageDown moves the visible list forward by x items. Where x is the size of 15 | // the visible items on the list 16 | PageDown() 17 | 18 | // CanPageDown returns whether a list can still PageDown(). 19 | CanPageDown() bool 20 | 21 | // CanPageUp returns whether a list can still PageUp() 22 | CanPageUp() bool 23 | 24 | // Search allows the list to be filtered by a given term. 25 | Search(term string) 26 | 27 | // CancelSearch stops the current search and returns the list to its original order. 28 | CancelSearch() 29 | 30 | // Start returns the current render start position of the list. 31 | Start() int 32 | 33 | // SetStart sets the current scroll position. Values out of bounds will be clamped. 34 | SetStart(i int) 35 | 36 | // SetCursor sets the position of the cursor in the list. Values out of bounds will 37 | // be clamped. 38 | SetCursor(i int) 39 | 40 | // Index returns the index of the item currently selected inside the searched list 41 | Index() int 42 | 43 | // Items returns a slice equal to the size of the list with the current visible 44 | // items and the index of the active item in this list. 45 | Items() ([]interface{}, int) 46 | 47 | // Matches returns the matched items against a search term 48 | Matches(key interface{}) []int 49 | 50 | // Cursor is the current cursor position 51 | Cursor() int 52 | 53 | // Size is the number of items to be displayed 54 | Size() int 55 | 56 | Update() chan struct{} 57 | } 58 | -------------------------------------------------------------------------------- /prompt/prompt.go: -------------------------------------------------------------------------------- 1 | package prompt 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/signal" 7 | "sync" 8 | "syscall" 9 | "time" 10 | "unicode/utf8" 11 | 12 | "github.com/fatih/color" 13 | "github.com/isacikgoz/gitin/term" 14 | ) 15 | 16 | type keyEvent struct { 17 | ch rune 18 | err error 19 | } 20 | 21 | // KeyBinding is used for mapping a key to a function 22 | type KeyBinding struct { 23 | Key rune 24 | Display string 25 | Handler func(interface{}) error 26 | Desc string 27 | } 28 | 29 | type selectionHandlerFunc func(interface{}) error 30 | type itemRendererFunc func(interface{}, []int, bool) [][]term.Cell 31 | type informationRendererFunc func(interface{}) [][]term.Cell 32 | 33 | // OptionalFunc handles functional arguments of the prompt 34 | type OptionalFunc func(*Prompt) 35 | 36 | // Options is the common options for building a prompt 37 | type Options struct { 38 | LineSize int `default:"5"` 39 | StartInSearch bool 40 | DisableColor bool 41 | VimKeys bool `default:"true"` 42 | } 43 | 44 | // State holds the changeable vars of the prompt 45 | type State struct { 46 | List List 47 | SearchMode bool 48 | SearchStr string 49 | SearchLabel string 50 | Cursor int 51 | Scroll int 52 | ListSize int 53 | } 54 | 55 | // Prompt is a interactive prompt for command-line 56 | type Prompt struct { 57 | list List 58 | opts *Options 59 | keyBindings []*KeyBinding 60 | 61 | selectionHandler selectionHandlerFunc 62 | itemRenderer itemRendererFunc 63 | informationRenderer informationRendererFunc 64 | 65 | exitMsg [][]term.Cell // to be set on runtime if required 66 | 67 | inputMode bool 68 | helpMode bool 69 | itemsLabel string 70 | input string 71 | 72 | reader *term.RuneReader // initialized by prompt 73 | writer *term.BufferedWriter // initialized by prompt 74 | mx *sync.RWMutex 75 | 76 | events chan keyEvent 77 | quit chan struct{} 78 | newItem chan struct{} 79 | } 80 | 81 | // Create returns a pointer to prompt that is ready to Run 82 | func Create(label string, opts *Options, list List, fs ...OptionalFunc) *Prompt { 83 | p := &Prompt{ 84 | opts: opts, 85 | list: list, 86 | itemsLabel: label, 87 | itemRenderer: itemText, 88 | reader: term.NewRuneReader(os.Stdin), 89 | writer: term.NewBufferedWriter(os.Stdout), 90 | mx: &sync.RWMutex{}, 91 | events: make(chan keyEvent, 20), 92 | quit: make(chan struct{}, 1), 93 | newItem: make(chan struct{}), 94 | } 95 | 96 | for _, f := range fs { 97 | f(p) 98 | } 99 | return p 100 | } 101 | 102 | // WithSelectionHandler adds a selection handler to the prompt 103 | func WithSelectionHandler(f selectionHandlerFunc) OptionalFunc { 104 | return func(p *Prompt) { 105 | p.selectionHandler = f 106 | } 107 | } 108 | 109 | // WithItemRenderer to add your own implementation on rendering an Item 110 | func WithItemRenderer(f itemRendererFunc) OptionalFunc { 111 | return func(p *Prompt) { 112 | p.itemRenderer = f 113 | } 114 | } 115 | 116 | // WithInformation adds additional information below to the prompt 117 | func WithInformation(f informationRendererFunc) OptionalFunc { 118 | return func(p *Prompt) { 119 | p.informationRenderer = f 120 | } 121 | } 122 | 123 | // Run as name implies starts the prompt until it quits 124 | func (p *Prompt) Run(ctx context.Context) error { 125 | // disable echo and hide cursor 126 | if err := term.Init(os.Stdin, os.Stdout); err != nil { 127 | return err 128 | } 129 | defer term.Close() 130 | 131 | if p.opts.DisableColor { 132 | term.DisableColor() 133 | } 134 | 135 | if p.opts.StartInSearch { 136 | p.inputMode = true 137 | } 138 | ctx, cancel := context.WithCancel(ctx) 139 | defer cancel() 140 | // start input loop 141 | go p.spawnEvents(ctx) 142 | 143 | p.render() // start with an initial render 144 | 145 | err := p.mainloop() 146 | 147 | // reset cursor position and remove buffer 148 | p.writer.Reset() 149 | _ = p.writer.ClearScreen() 150 | 151 | if err != nil { 152 | return err 153 | } 154 | 155 | for _, cells := range p.exitMsg { 156 | _, _ = p.writer.WriteCells(cells) 157 | } 158 | _ = p.writer.Flush() 159 | 160 | return nil 161 | } 162 | 163 | // Stop sends a quit signal to the main loop of the prompt 164 | func (p *Prompt) Stop() { 165 | p.quit <- struct{}{} 166 | } 167 | 168 | func (p *Prompt) spawnEvents(ctx context.Context) { 169 | for { 170 | select { 171 | case <-ctx.Done(): 172 | return 173 | case <-time.After(10 * time.Millisecond): 174 | p.mx.Lock() 175 | r, _, err := p.reader.ReadRune() 176 | p.mx.Unlock() 177 | p.events <- keyEvent{ch: r, err: err} 178 | } 179 | } 180 | } 181 | 182 | // this is the main loop for reading input channel 183 | func (p *Prompt) mainloop() error { 184 | sigwinch := make(chan os.Signal, 1) 185 | defer close(sigwinch) 186 | signal.Notify(sigwinch, syscall.SIGWINCH) 187 | 188 | for { 189 | select { 190 | case <-p.quit: 191 | return nil 192 | case <-sigwinch: 193 | p.render() 194 | case <-p.list.Update(): 195 | p.render() 196 | case ev := <-p.events: 197 | if err := func() error { 198 | p.mx.Lock() 199 | defer p.mx.Unlock() 200 | 201 | if err := ev.err; err != nil { 202 | return err 203 | } 204 | 205 | switch r := ev.ch; r { 206 | case rune(term.KeyCtrlC), rune(term.KeyCtrlD): 207 | p.Stop() 208 | return nil 209 | case term.Enter, term.NewLine: 210 | items, idx := p.list.Items() 211 | if idx == NotFound { 212 | break 213 | } 214 | 215 | if err := p.selectionHandler(items[idx]); err != nil { 216 | return err 217 | } 218 | default: 219 | if err := p.onKey(r); err != nil { 220 | return err 221 | } 222 | } 223 | p.render() 224 | return nil 225 | }(); err != nil { 226 | return err 227 | } 228 | } 229 | } 230 | } 231 | 232 | // render function draws screen's list to terminal 233 | func (p *Prompt) render() { 234 | defer func() { 235 | p.writer.Flush() 236 | 237 | }() 238 | 239 | if p.helpMode { 240 | for _, line := range genHelp(p.allControls()) { 241 | _, _ = p.writer.WriteCells(line) 242 | } 243 | return 244 | } 245 | 246 | items, idx := p.list.Items() 247 | _, _ = p.writer.WriteCells(renderSearch(p.itemsLabel, p.inputMode, p.input)) 248 | 249 | for i := range items { 250 | output := p.itemRenderer(items[i], p.list.Matches(items[i]), (i == idx)) 251 | for _, l := range output { 252 | _, _ = p.writer.WriteCells(l) 253 | } 254 | } 255 | 256 | _, _ = p.writer.WriteCells(nil) // add an empty line 257 | if idx != NotFound { 258 | for _, line := range p.informationRenderer(items[idx]) { 259 | _, _ = p.writer.WriteCells(line) 260 | } 261 | } else { 262 | _, _ = p.writer.WriteCells(term.Cprint("Not found.", color.FgRed)) 263 | } 264 | } 265 | 266 | // AddKeyBinding adds a key-function map to prompt 267 | func (p *Prompt) AddKeyBinding(b *KeyBinding) error { 268 | p.keyBindings = append(p.keyBindings, b) 269 | return nil 270 | } 271 | 272 | // default key handling function 273 | func (p *Prompt) onKey(key rune) error { 274 | if p.helpMode { 275 | p.helpMode = false 276 | return nil 277 | } 278 | 279 | switch key { 280 | case term.ArrowUp: 281 | p.list.Prev() 282 | case term.ArrowDown: 283 | p.list.Next() 284 | case term.ArrowLeft: 285 | p.list.PageDown() 286 | case term.ArrowRight: 287 | p.list.PageUp() 288 | default: 289 | 290 | if key == '/' { 291 | p.inputMode = !p.inputMode 292 | } else if p.inputMode { 293 | switch key { 294 | case term.Backspace, term.Backspace2: 295 | if len(p.input) > 0 { 296 | _, size := utf8.DecodeLastRuneInString(p.input) 297 | p.input = p.input[0 : len(p.input)-size] 298 | } 299 | case rune(term.KeyCtrlU): 300 | p.input = "" 301 | default: 302 | p.input += string(key) 303 | } 304 | p.list.Search(p.input) 305 | } else if key == '?' { 306 | p.helpMode = !p.helpMode 307 | } else if p.opts.VimKeys && key == 'k' { 308 | // refactor vim keys 309 | p.list.Prev() 310 | } else if p.opts.VimKeys && key == 'j' { 311 | p.list.Next() 312 | } else if p.opts.VimKeys && key == 'h' { 313 | p.list.PageDown() 314 | } else if p.opts.VimKeys && key == 'l' { 315 | p.list.PageUp() 316 | } else { 317 | items, idx := p.list.Items() 318 | if idx == NotFound { 319 | return nil 320 | } 321 | 322 | for _, kb := range p.keyBindings { 323 | if kb.Key == key { 324 | return kb.Handler(items[idx]) 325 | } 326 | } 327 | } 328 | } 329 | 330 | return nil 331 | } 332 | 333 | func (p *Prompt) allControls() map[string]string { 334 | controls := make(map[string]string) 335 | controls["← ↓ ↑ → (h,j,k,l)"] = "navigation" 336 | controls["/"] = "toggle search" 337 | for _, kb := range p.keyBindings { 338 | controls[kb.Display] = kb.Desc 339 | } 340 | return controls 341 | } 342 | 343 | // State return the current replace-able vars as a struct 344 | func (p *Prompt) State() *State { 345 | scroll := p.list.Start() 346 | return &State{ 347 | List: p.list, 348 | SearchMode: p.inputMode, 349 | SearchStr: p.input, 350 | SearchLabel: p.itemsLabel, 351 | Cursor: p.list.Cursor(), 352 | Scroll: scroll, 353 | ListSize: p.list.Size(), 354 | } 355 | } 356 | 357 | // SetState replaces the state of the prompt 358 | func (p *Prompt) SetState(state *State) { 359 | p.list = state.List 360 | p.inputMode = state.SearchMode 361 | p.input = state.SearchStr 362 | p.itemsLabel = state.SearchLabel 363 | p.list.SetCursor(state.Cursor) 364 | p.list.SetStart(state.Scroll) 365 | } 366 | 367 | // ListSize returns the size of the items that is renderer each time 368 | func (p *Prompt) ListSize() int { 369 | return p.opts.LineSize 370 | } 371 | 372 | // SetExitMsg adds a rendered cell grid to be printed after prompt is finished 373 | func (p *Prompt) SetExitMsg(grid [][]term.Cell) { 374 | p.exitMsg = grid 375 | } 376 | -------------------------------------------------------------------------------- /prompt/renderer.go: -------------------------------------------------------------------------------- 1 | package prompt 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | 7 | "github.com/fatih/color" 8 | "github.com/isacikgoz/gitin/term" 9 | ) 10 | 11 | func itemText(item interface{}, matches []int, selected bool) [][]term.Cell { 12 | var line []term.Cell 13 | text := fmt.Sprint(item) 14 | if selected { 15 | line = append(line, term.Cprint("> ", color.FgCyan)...) 16 | } else { 17 | line = append(line, term.Cprint(" ", color.FgWhite)...) 18 | } 19 | if len(matches) == 0 { 20 | return [][]term.Cell{append(line, term.Cprint(text)...)} 21 | } 22 | highlighted := make([]term.Cell, 0) 23 | for _, r := range text { 24 | highlighted = append(highlighted, term.Cell{ 25 | Ch: r, 26 | }) 27 | } 28 | for _, m := range matches { 29 | if m > len(highlighted)-1 { 30 | continue 31 | } 32 | highlighted[m] = term.Cell{ 33 | Ch: highlighted[m].Ch, 34 | Attr: append(highlighted[m].Attr, color.Underline), 35 | } 36 | } 37 | line = append(line, highlighted...) 38 | return [][]term.Cell{line} 39 | } 40 | 41 | // returns multiline so the return value will be a 2-d slice 42 | func genHelp(pairs map[string]string) [][]term.Cell { 43 | var grid [][]term.Cell 44 | n := map[string][]string{} 45 | // sort keys alphabetically, sort by values 46 | keys := make([]string, 0, len(pairs)) 47 | for k, v := range pairs { 48 | n[v] = append(n[v], k) 49 | } 50 | for k := range n { 51 | keys = append(keys, k) 52 | } 53 | sort.Strings(keys) 54 | for _, key := range keys { 55 | grid = append(grid, append(term.Cprint(fmt.Sprintf("%s: ", key), color.Faint), 56 | term.Cprint(n[key][0], color.FgYellow)...)) 57 | } 58 | grid = append(grid, term.Cprint("", 0)) 59 | grid = append(grid, term.Cprint("press any key to return.", color.Faint)) 60 | return grid 61 | } 62 | 63 | func renderSearch(placeholder string, inputMode bool, input string) []term.Cell { 64 | var cells []term.Cell 65 | if inputMode { 66 | cells = term.Cprint("Search ", color.Faint) 67 | cells = append(cells, term.Cprint(placeholder+" ", color.Faint)...) 68 | cells = append(cells, term.Cprint(input, color.FgWhite)...) 69 | cells = append(cells, term.Cprint("█", color.Faint, color.BlinkRapid)...) 70 | return cells 71 | } 72 | cells = term.Cprint(placeholder, color.Faint) 73 | if len(input) > 0 { 74 | cells = append(cells, term.Cprint(" /"+input, color.FgWhite)...) 75 | } 76 | 77 | return cells 78 | } 79 | -------------------------------------------------------------------------------- /prompt/sync_list.go: -------------------------------------------------------------------------------- 1 | // Package prompt is a slightly modified version of promptui's list. The original 2 | // version can be found at https://github.com/manifoldco/promptui 3 | // A little copying is better than a little dependency. - Go proverbs. 4 | package prompt 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "reflect" 10 | "sort" 11 | "strings" 12 | 13 | "github.com/isacikgoz/fuzzy" 14 | ) 15 | 16 | type interfaceSource []interface{} 17 | 18 | func (is interfaceSource) String(i int) string { return fmt.Sprint(is[i]) } 19 | 20 | func (is interfaceSource) Len() int { return len(is) } 21 | 22 | // NotFound is an index returned when no item was selected. 23 | const NotFound = -1 24 | 25 | // SyncList holds a collection of items that can be displayed with an N number of 26 | // visible items. The list can be moved up, down by one item of time or an 27 | // entire page (ie: visible size). It keeps track of the current selected item. 28 | type SyncList struct { 29 | items []interface{} 30 | scope []interface{} 31 | matches map[interface{}][]int 32 | cursor int // cursor holds the index of the current selected item 33 | size int // size is the number of visible options 34 | start int 35 | find string 36 | } 37 | 38 | // NewList creates and initializes a list of searchable items. The items attribute must be a slice type. 39 | func NewList(items interface{}, size int) (*SyncList, error) { 40 | if size < 1 { 41 | return nil, fmt.Errorf("list size %d must be greater than 0", size) 42 | } 43 | if items == nil || reflect.TypeOf(items).Kind() != reflect.Slice { 44 | return nil, fmt.Errorf("items %v is not a slice", items) 45 | } 46 | 47 | slice := reflect.ValueOf(items) 48 | values := make([]interface{}, slice.Len()) 49 | 50 | for i := range values { 51 | item := slice.Index(i) 52 | values[i] = item.Interface() 53 | } 54 | 55 | return &SyncList{ 56 | size: size, 57 | items: values, 58 | scope: values, 59 | }, nil 60 | } 61 | 62 | // Prev moves the visible list back one item. 63 | func (l *SyncList) Prev() { 64 | if l.cursor > 0 { 65 | l.cursor-- 66 | } 67 | 68 | if l.start > l.cursor { 69 | l.start = l.cursor 70 | } 71 | } 72 | 73 | // Search allows the list to be filtered by a given term. 74 | func (l *SyncList) Search(term string) { 75 | term = strings.Trim(term, " ") 76 | l.cursor = 0 77 | l.start = 0 78 | l.find = term 79 | l.search(term) 80 | } 81 | 82 | // CancelSearch stops the current search and returns the list to its original order. 83 | func (l *SyncList) CancelSearch() { 84 | l.cursor = 0 85 | l.start = 0 86 | l.scope = l.items 87 | } 88 | 89 | func (l *SyncList) search(term string) { 90 | if len(term) == 0 { 91 | l.scope = l.items 92 | return 93 | } 94 | l.matches = make(map[interface{}][]int) 95 | matches := fuzzy.FindFrom(context.Background(), term, interfaceSource(l.items)) 96 | 97 | results := make([]fuzzy.Match, 0) 98 | for match := range matches { 99 | results = append(results, match) 100 | } 101 | 102 | sort.Stable(fuzzy.Sortable(results)) 103 | 104 | l.scope = make([]interface{}, 0) 105 | for _, r := range results { 106 | item := l.items[r.Index] 107 | l.scope = append(l.scope, item) 108 | l.matches[item] = r.MatchedIndexes 109 | } 110 | } 111 | 112 | // Start returns the current render start position of the list. 113 | func (l *SyncList) Start() int { 114 | return l.start 115 | } 116 | 117 | // SetStart sets the current scroll position. Values out of bounds will be clamped. 118 | func (l *SyncList) SetStart(i int) { 119 | if i < 0 { 120 | i = 0 121 | } 122 | if i > l.cursor { 123 | l.start = l.cursor 124 | } else { 125 | l.start = i 126 | } 127 | } 128 | 129 | // SetCursor sets the position of the cursor in the list. Values out of bounds will 130 | // be clamped. 131 | func (l *SyncList) SetCursor(i int) { 132 | max := len(l.scope) - 1 133 | if i >= max { 134 | i = max 135 | } 136 | if i < 0 { 137 | i = 0 138 | } 139 | l.cursor = i 140 | 141 | if l.start > l.cursor { 142 | l.start = l.cursor 143 | } else if l.start+l.size <= l.cursor { 144 | l.start = l.cursor - l.size + 1 145 | } 146 | } 147 | 148 | // Next moves the visible list forward one item. 149 | func (l *SyncList) Next() { 150 | max := len(l.scope) - 1 151 | 152 | if l.cursor < max { 153 | l.cursor++ 154 | } 155 | 156 | if l.start+l.size <= l.cursor { 157 | l.start = l.cursor - l.size + 1 158 | } 159 | } 160 | 161 | // PageUp moves the visible list backward by x items. Where x is the size of the 162 | // visible items on the list. 163 | func (l *SyncList) PageUp() { 164 | start := l.start - l.size 165 | if start < 0 { 166 | l.start = 0 167 | } else { 168 | l.start = start 169 | } 170 | 171 | cursor := l.start 172 | 173 | if cursor < l.cursor { 174 | l.cursor = cursor 175 | } 176 | } 177 | 178 | // PageDown moves the visible list forward by x items. Where x is the size of 179 | // the visible items on the list. 180 | func (l *SyncList) PageDown() { 181 | start := l.start + l.size 182 | max := len(l.scope) - l.size 183 | 184 | switch { 185 | case len(l.scope) < l.size: 186 | l.start = 0 187 | case start > max: 188 | l.start = max 189 | default: 190 | l.start = start 191 | } 192 | 193 | cursor := l.start 194 | 195 | if cursor == l.cursor { 196 | l.cursor = len(l.scope) - 1 197 | } else if cursor > l.cursor { 198 | l.cursor = cursor 199 | } 200 | } 201 | 202 | // CanPageDown returns whether a list can still PageDown(). 203 | func (l *SyncList) CanPageDown() bool { 204 | max := len(l.scope) 205 | return l.start+l.size < max 206 | } 207 | 208 | // CanPageUp returns whether a list can still PageUp(). 209 | func (l *SyncList) CanPageUp() bool { 210 | return l.start > 0 211 | } 212 | 213 | // Index returns the index of the item currently selected inside the searched list. 214 | func (l *SyncList) Index() int { 215 | if len(l.scope) <= 0 { 216 | return 0 217 | } 218 | selected := l.scope[l.cursor] 219 | 220 | for i, item := range l.items { 221 | if item == selected { 222 | return i 223 | } 224 | } 225 | 226 | return NotFound 227 | } 228 | 229 | // Items returns a slice equal to the size of the list with the current visible 230 | // items and the index of the active item in this list. 231 | func (l *SyncList) Items() ([]interface{}, int) { 232 | var result []interface{} 233 | max := len(l.scope) 234 | end := l.start + l.size 235 | 236 | if end > max { 237 | end = max 238 | } 239 | 240 | active := NotFound 241 | 242 | for i, j := l.start, 0; i < end; i, j = i+1, j+1 { 243 | if l.cursor == i { 244 | active = j 245 | } 246 | 247 | result = append(result, l.scope[i]) 248 | } 249 | 250 | return result, active 251 | } 252 | 253 | func (l *SyncList) Size() int { 254 | return l.size 255 | } 256 | 257 | func (l *SyncList) Cursor() int { 258 | return l.cursor 259 | } 260 | 261 | func (l *SyncList) Matches(item interface{}) []int { 262 | return l.matches[item] 263 | } 264 | 265 | func (l *SyncList) Update() chan struct{} { 266 | return nil 267 | } 268 | -------------------------------------------------------------------------------- /term/bufferedreader.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | ) 7 | 8 | // BufferedReader fullfills the io.Reader interface 9 | type BufferedReader struct { 10 | In io.Reader 11 | Buffer *bytes.Buffer 12 | } 13 | 14 | func (br *BufferedReader) Read(p []byte) (int, error) { 15 | n, err := br.Buffer.Read(p) 16 | if err != nil && err != io.EOF { 17 | return n, err 18 | } else if err == nil { 19 | return n, nil 20 | } 21 | 22 | return br.In.Read(p[n:]) 23 | } 24 | -------------------------------------------------------------------------------- /term/bufferedwriter.go: -------------------------------------------------------------------------------- 1 | // Package term is influenced by https://github.com/AlecAivazis/survey and 2 | // https://github.com/manifoldco/promptui it might contain some code snippets from those 3 | // A little copying is better than a little dependency. - Go proverbs. 4 | package term 5 | 6 | import ( 7 | "bytes" 8 | "fmt" 9 | "io" 10 | 11 | "github.com/fatih/color" 12 | ) 13 | 14 | // BufferedWriter creates, clears and, moves up or down lines as needed to write 15 | // the output to the terminal using ANSI escape codes. 16 | type BufferedWriter struct { 17 | w io.Writer 18 | buf *bytes.Buffer 19 | lineWrap bool 20 | reset bool 21 | cursor int 22 | height int 23 | } 24 | 25 | // NewBufferedWriter creates and initializes a new BufferedWriter. 26 | func NewBufferedWriter(w io.Writer) *BufferedWriter { 27 | return &BufferedWriter{buf: &bytes.Buffer{}, w: w} 28 | } 29 | 30 | // Reset truncates the underlining buffer and marks all its previous lines to be 31 | // cleared during the next Write. 32 | func (b *BufferedWriter) Reset() { 33 | b.buf.Reset() 34 | b.reset = true 35 | } 36 | 37 | // Write writes a single line to the underlining buffer. 38 | func (b *BufferedWriter) Write(bites []byte) (int, error) { 39 | if bytes.ContainsAny(bites, "\r\n") { 40 | return 0, fmt.Errorf("%q should not contain either \\r or \\n", bites) 41 | } 42 | 43 | if !b.lineWrap { 44 | b.buf.Write([]byte(lwoff)) 45 | defer b.buf.Write([]byte(lwon)) 46 | } 47 | 48 | if b.reset { 49 | for i := 0; i < b.height; i++ { 50 | _, err := b.buf.Write(moveUp) 51 | if err != nil { 52 | return 0, err 53 | } 54 | _, err = b.buf.Write(clearLine) 55 | if err != nil { 56 | return 0, err 57 | } 58 | } 59 | b.cursor = 0 60 | b.height = 0 61 | b.reset = false 62 | } 63 | 64 | switch { 65 | case b.cursor == b.height: 66 | n, err := b.buf.Write(clearLine) 67 | if err != nil { 68 | return n, err 69 | } 70 | line := append(bites, []byte("\n")...) 71 | n, err = b.buf.Write(line) 72 | if err != nil { 73 | return n, err 74 | } 75 | b.height++ 76 | b.cursor++ 77 | return n, nil 78 | case b.cursor < b.height: 79 | n, err := b.buf.Write(clearLine) 80 | if err != nil { 81 | return n, err 82 | } 83 | n, err = b.buf.Write(bites) 84 | if err != nil { 85 | return n, err 86 | } 87 | n, err = b.buf.Write(moveDown) 88 | if err != nil { 89 | return n, err 90 | } 91 | b.cursor++ 92 | return n, nil 93 | default: 94 | return 0, fmt.Errorf("Invalid write cursor position (%d) exceeded line height: %d", b.cursor, b.height) 95 | } 96 | } 97 | 98 | // WriteCells add colored text to the inner buffer 99 | func (b *BufferedWriter) WriteCells(cs []Cell) (int, error) { 100 | bs := make([]byte, 0) 101 | if colored { 102 | for _, c := range cs { 103 | paint := color.New(c.Attr...) 104 | painted := paint.Sprintf(string(c.Ch)) 105 | bs = append(bs, []byte(painted)...) 106 | } 107 | } else { 108 | for _, c := range cs { 109 | bs = append(bs, []byte(string(c.Ch))...) 110 | } 111 | } 112 | return b.Write(bs) 113 | } 114 | 115 | // Flush writes any buffered data to the underlying io.Writer, ensuring that any pending data is displayed. 116 | func (b *BufferedWriter) Flush() error { 117 | for i := b.cursor; i < b.height; i++ { 118 | if i < b.height { 119 | _, err := b.buf.Write(clearLine) 120 | if err != nil { 121 | return err 122 | } 123 | } 124 | _, err := b.buf.Write(moveDown) 125 | if err != nil { 126 | return err 127 | } 128 | } 129 | 130 | _, err := b.buf.WriteTo(b.w) 131 | if err != nil { 132 | return err 133 | } 134 | b.buf.Reset() 135 | // reset cursor position 136 | b.buf.Write(clearLine) 137 | _, err = b.buf.WriteTo(b.w) 138 | if err != nil { 139 | return err 140 | } 141 | b.buf.Reset() 142 | 143 | for i := 0; i < b.height; i++ { 144 | _, err := b.buf.Write(moveUp) 145 | if err != nil { 146 | return err 147 | } 148 | } 149 | 150 | b.cursor = 0 151 | 152 | return nil 153 | } 154 | 155 | // ClearScreen solves problems (R) and use it after Reset() 156 | func (b *BufferedWriter) ClearScreen() error { 157 | for i := 0; i < b.height; i++ { 158 | _, err := b.buf.Write(moveUp) 159 | if err != nil { 160 | return err 161 | } 162 | _, err = b.buf.Write(clearLine) 163 | if err != nil { 164 | return err 165 | } 166 | } 167 | b.cursor = 0 168 | b.height = 0 169 | b.reset = false 170 | 171 | _, err := b.buf.WriteTo(b.w) 172 | if err != nil { 173 | return err 174 | } 175 | b.buf.Reset() 176 | return nil 177 | } 178 | 179 | // ShowCursor writes to os.Stdout that to show cursor 180 | func (b *BufferedWriter) ShowCursor() { 181 | _, _ = b.w.Write([]byte(showCursor)) 182 | } 183 | 184 | // HideCursor writes to os.Stdout that to hide cursor 185 | func (b *BufferedWriter) HideCursor() { 186 | _, _ = b.w.Write([]byte(hideCursor)) 187 | } 188 | -------------------------------------------------------------------------------- /term/consants_bsd.go: -------------------------------------------------------------------------------- 1 | // +build darwin dragonfly freebsd netbsd openbsd 2 | 3 | package term 4 | 5 | import "syscall" 6 | 7 | const ( 8 | ioctlReadTermios = syscall.TIOCGETA 9 | ioctlWriteTermios = syscall.TIOCSETA 10 | ) 11 | -------------------------------------------------------------------------------- /term/constants_common.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | const ( 4 | esc = "\033[" 5 | // HideCursor writes the sequence for hiding cursor 6 | hideCursor = "\x1b[?25l" 7 | // ShowCursor writes the sequence for resotring show cursor 8 | showCursor = "\x1b[?25h" 9 | // LineWrapOff sets the terminal to avoid line wrap 10 | lwoff = "\x1b[?7l" 11 | // LineWrapOn restores the linewrap setting 12 | lwon = "\x1b[?7h" 13 | ) 14 | 15 | var ( 16 | clearLine = []byte(esc + "2K\r") 17 | moveUp = []byte(esc + "1A") 18 | moveDown = []byte(esc + "1B") 19 | ) 20 | -------------------------------------------------------------------------------- /term/constants_linux.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | const ( 4 | ioctlReadTermios = 0x5401 // syscall.TCGETS 5 | ioctlWriteTermios = 0x5402 // syscall.TCSETS 6 | ) 7 | -------------------------------------------------------------------------------- /term/keycodes.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | // These are the key that aliases 4 | const ( 5 | ArrowLeft = rune(KeyCtrlB) 6 | ArrowRight = rune(KeyCtrlF) 7 | ArrowUp = rune(KeyCtrlP) 8 | ArrowDown = rune(KeyCtrlN) 9 | Space = ' ' 10 | Enter = '\r' 11 | NewLine = '\n' 12 | Backspace = rune(KeyCtrlH) 13 | Backspace2 = rune(KeyDEL) 14 | ) 15 | 16 | // Key is the ascii codes of a keys 17 | type Key int16 18 | 19 | // These are the control keys. Note that they overlap with other keys. 20 | const ( 21 | KeyCtrlSpace Key = iota 22 | KeyCtrlA // KeySOH 23 | KeyCtrlB // KeySTX 24 | KeyCtrlC // KeyETX 25 | KeyCtrlD // KeyEOT 26 | KeyCtrlE // KeyENQ 27 | KeyCtrlF // KeyACK 28 | KeyCtrlG // KeyBEL 29 | KeyCtrlH // KeyBS 30 | KeyCtrlI // KeyTAB 31 | KeyCtrlJ // KeyLF 32 | KeyCtrlK // KeyVT 33 | KeyCtrlL // KeyFF 34 | KeyCtrlM // KeyCR 35 | KeyCtrlN // KeySO 36 | KeyCtrlO // KeySI 37 | KeyCtrlP // KeyDLE 38 | KeyCtrlQ // KeyDC1 39 | KeyCtrlR // KeyDC2 40 | KeyCtrlS // KeyDC3 41 | KeyCtrlT // KeyDC4 42 | KeyCtrlU // KeyNAK 43 | KeyCtrlV // KeySYN 44 | KeyCtrlW // KeyETB 45 | KeyCtrlX // KeyCAN 46 | KeyCtrlY // KeyEM 47 | KeyCtrlZ // KeySUB 48 | KeyESC // KeyESC 49 | KeyCtrlBackslash // KeyFS 50 | KeyCtrlRightSq // KeyGS 51 | KeyCtrlCarat // KeyRS 52 | KeyCtrlUnderscore // KeyUS 53 | KeyDEL = 0x7F 54 | ) 55 | -------------------------------------------------------------------------------- /term/runereader.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | // This is a modified version of survey's runereader. The original version can 5 | // be found at https://github.com/AlecAivazis/survey 6 | 7 | package term 8 | 9 | import ( 10 | "fmt" 11 | ) 12 | 13 | // RuneReader reads from an io.Reader interface 14 | type RuneReader struct { 15 | in Reader 16 | } 17 | 18 | // NewRuneReader creates a new instance of RuneReader 19 | func NewRuneReader(reader Reader) *RuneReader { 20 | return &RuneReader{ 21 | in: reader, 22 | } 23 | } 24 | 25 | // ReadRune returns a single rune from the stdin 26 | func (rr *RuneReader) ReadRune() (rune, int, error) { 27 | r, size, err := state.reader.ReadRune() 28 | if err != nil { 29 | return r, size, err 30 | } 31 | 32 | // parse ^[ sequences to look for arrow keys 33 | if r == '\033' { 34 | if state.reader.Buffered() == 0 { 35 | // no more characters so must be `Esc` key 36 | return rune(KeyESC), 1, nil 37 | } 38 | r, size, err = state.reader.ReadRune() 39 | if err != nil { 40 | return r, size, err 41 | } 42 | if r != '[' { 43 | return r, size, fmt.Errorf("Unexpected Escape Sequence: %q", []rune{'\033', r}) 44 | } 45 | r, size, err = state.reader.ReadRune() 46 | if err != nil { 47 | return r, size, err 48 | } 49 | switch r { 50 | case 'D': 51 | return ArrowLeft, 1, nil 52 | case 'C': 53 | return ArrowRight, 1, nil 54 | case 'A': 55 | return ArrowUp, 1, nil 56 | case 'B': 57 | return ArrowDown, 1, nil 58 | case 'H': // Home button 59 | return rune(KeyCtrlA), 1, nil 60 | case 'F': // End button 61 | return rune(KeyCtrlQ), 1, nil 62 | case '3': // Delete Button 63 | // discard the following '~' key from buffer 64 | _, _ = state.reader.Discard(1) 65 | return rune(KeyCtrlR), 1, nil 66 | default: 67 | // discard the following '~' key from buffer 68 | _, _ = state.reader.Discard(1) 69 | return rune(KeyCtrlSpace), 1, nil 70 | } 71 | } 72 | return r, size, err 73 | } 74 | -------------------------------------------------------------------------------- /term/terminal.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "io" 7 | "syscall" 8 | "unsafe" 9 | 10 | "github.com/fatih/color" 11 | ) 12 | 13 | var ( 14 | state terminalState 15 | reader Reader 16 | writer Writer 17 | colored = true 18 | ) 19 | 20 | type terminalState struct { 21 | term syscall.Termios 22 | reader *bufio.Reader 23 | buf *bytes.Buffer 24 | } 25 | 26 | // Writer provides a minimal interface for Stdin. 27 | type Writer interface { 28 | io.Writer 29 | Fd() uintptr 30 | } 31 | 32 | // Reader provides a minimal interface for Stdout. 33 | type Reader interface { 34 | io.Reader 35 | Fd() uintptr 36 | } 37 | 38 | // Cell is a single character that will be drawn to the terminal 39 | type Cell struct { 40 | Ch rune 41 | Attr []color.Attribute 42 | } 43 | 44 | // Init initializes the term package 45 | func Init(r Reader, w Writer) error { 46 | reader = r 47 | writer = w 48 | state = newTerminalState(reader) 49 | if _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, uintptr(reader.Fd()), ioctlReadTermios, uintptr(unsafe.Pointer(&state.term)), 0, 0, 0); err != 0 { 50 | return err 51 | } 52 | 53 | newState := state.term 54 | // syscall.ECHO | syscall.ECHONL | syscall.ICANON to disable echo 55 | // syscall.ISIG is to catch keys like ctr-c or ctrl-d 56 | newState.Lflag &^= syscall.ECHO | syscall.ECHONL | syscall.ICANON | syscall.ISIG 57 | 58 | if _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, uintptr(reader.Fd()), ioctlWriteTermios, uintptr(unsafe.Pointer(&newState)), 0, 0, 0); err != 0 { 59 | return err 60 | } 61 | _, err := writer.Write([]byte(hideCursor)) 62 | return err 63 | } 64 | 65 | // Close restores the terminal state 66 | func Close() error { 67 | if _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, uintptr(reader.Fd()), ioctlWriteTermios, uintptr(unsafe.Pointer(&state.term)), 0, 0, 0); err != 0 { 68 | return err 69 | } 70 | _, err := writer.Write([]byte(showCursor)) 71 | return err 72 | } 73 | 74 | func newTerminalState(input Reader) terminalState { 75 | buf := new(bytes.Buffer) 76 | return terminalState{ 77 | reader: bufio.NewReader(&BufferedReader{ 78 | In: input, 79 | Buffer: buf, 80 | }), 81 | buf: buf, 82 | } 83 | } 84 | 85 | // Cprint returns the text as colored cell slice 86 | func Cprint(text string, attrs ...color.Attribute) []Cell { 87 | cells := make([]Cell, 0) 88 | for _, ch := range text { 89 | cells = append(cells, Cell{ 90 | Ch: ch, 91 | Attr: attrs, 92 | }) 93 | } 94 | return cells 95 | } 96 | 97 | // DisableColor makes cell attributes meaningless 98 | func DisableColor() { 99 | colored = false 100 | } 101 | --------------------------------------------------------------------------------