├── .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 |  
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 |
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 |
--------------------------------------------------------------------------------