├── .gitignore
├── .goreleaser.yml
├── .travis.yml
├── Formula
└── fac.rb
├── LICENSE
├── README.md
├── assets
├── banner.png
├── banner.sketch
├── doc
│ ├── convert-to-man
│ ├── fac.1
│ └── fac.1.md
└── screenshot.png
├── binding
├── .fac.yml
├── README.md
├── binding.go
└── binding_test.go
├── color
├── color.go
└── color_test.go
├── conflict
├── command.go
├── command_test.go
├── conflict.go
├── conflict_test.go
├── file.go
├── file_test.go
├── parse.go
├── parse_test.go
├── test.go
└── testdata
│ ├── CircularCrownSelector.swift
│ ├── assets
│ └── README.md
│ ├── invalid_lorem_ipsum
│ └── lorem_ipsum
├── editor
├── content.go
├── content_test.go
├── editor.go
├── editor_test.go
└── testdata
│ └── lorem_ipsum
├── go.mod
├── go.sum
├── layout.go
├── main.go
├── options.go
├── prompt.go
├── summary.go
└── testhelper
└── testhelper.go
/.gitignore:
--------------------------------------------------------------------------------
1 | ### Go ###
2 | # Binaries for programs and plugins
3 | *.exe
4 | *.exe~
5 | *.dll
6 | *.so
7 | *.dylib
8 | vendor
9 |
10 | # Test binary, build with `go test -c`
11 | *.test
12 | debug
13 |
14 | # goreleaser
15 | dist
16 |
17 | # Output of the go coverage tool, specifically when used with LiteIDE
18 | *.out
19 |
20 | # Dummy conflict code
21 | conflict/testdata/.test_output
22 | key/.fac.yml
23 |
24 | ### Code ###
25 | # Visual Studio Code - https://code.visualstudio.com/
26 | .settings/
27 | .vscode/
28 | tsconfig.json
29 | jsconfig.json
30 |
31 | ### macOS ###
32 | *.DS_Store
33 | .AppleDouble
34 | .LSOverride
35 |
36 | # Thumbnails
37 | ._*
38 |
39 | # Files that might appear in the root of a volume
40 | .DocumentRevisions-V100
41 | .fseventsd
42 | .Spotlight-V100
43 | .TemporaryItems
44 | .Trashes
45 | .VolumeIcon.icns
46 | .com.apple.timemachine.donotpresent
47 |
48 | # Directories potentially created on remote AFP share
49 | .AppleDB
50 | .AppleDesktop
51 | Network Trash Folder
52 | Temporary Items
53 | .apdisk
54 |
55 |
--------------------------------------------------------------------------------
/.goreleaser.yml:
--------------------------------------------------------------------------------
1 | project_name: fac
2 | release:
3 | github:
4 | owner: mkchoi212
5 | name: fac
6 | name_template: '{{.Tag}}'
7 |
8 | brew:
9 | commit_author:
10 | name: Mike JS. Choi
11 | email: mkchoi212@icloud.com
12 |
13 | github:
14 | owner: mkchoi212
15 | name: fac
16 |
17 | folder: Formula
18 | homepage: "https://github.com/mkchoi212/fac"
19 | description: "Command line User Interface for fixing git conflicts"
20 | install: bin.install "fac"
21 |
22 | builds:
23 | - goos:
24 | - linux
25 | - darwin
26 | goarch:
27 | - amd64
28 | - "386"
29 | goarm:
30 | - "6"
31 | main: .
32 | ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
33 | binary: fac
34 | archive:
35 | name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm
36 | }}v{{ .Arm }}{{ end }}'
37 | format: tar.gz
38 | files:
39 | - licence*
40 | - LICENCE*
41 | - license*
42 | - LICENSE*
43 | - readme*
44 | - README*
45 | - changelog*
46 | - CHANGELOG*
47 | fpm:
48 | name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm
49 | }}v{{ .Arm }}{{ end }}'
50 | bindir: /usr/local/bin
51 | snapcraft:
52 | name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm
53 | }}v{{ .Arm }}{{ end }}'
54 | snapshot:
55 | name_template: SNAPSHOT-{{ .Commit }}
56 | checksum:
57 | name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt'
58 | dist: dist
59 | sign:
60 | cmd: gpg
61 | args:
62 | - --output
63 | - $signature
64 | - --detach-sig
65 | - $artifact
66 | signature: ${artifact}.sig
67 | artifacts: none
68 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: go
2 |
3 | go:
4 | - master
5 | - tip
6 |
7 | before_install:
8 | - go get -t -v ./...
9 |
10 | script:
11 | - go test -race -coverprofile=coverage.txt -covermode=atomic `go list ./... | grep -v 'testhelper\|fac$'`
12 |
13 | after_success:
14 | - bash <(curl -s https://codecov.io/bash)
15 |
16 |
--------------------------------------------------------------------------------
/Formula/fac.rb:
--------------------------------------------------------------------------------
1 | class Fac < Formula
2 | desc "Command line User Interface for fixing git conflicts"
3 | homepage "https://github.com/mkchoi212/fac"
4 | url "https://github.com/mkchoi212/fac/releases/download/v2.0.0/fac_2.0.0_darwin_amd64.tar.gz"
5 | version "2.0.0"
6 | sha256 "8e33b3375169f19b5b6a508cd69287ce16d75ad6dc06c1d09d9684de5010c0eb"
7 |
8 | def install
9 | bin.install "fac"
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Mike JS. Choi
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Easy-to-use CUI for fixing git conflicts
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | I never really liked any of the `mergetools` out there so I made a program that is somewhat easier to use.
22 |
23 | 
24 |
25 | ## 👷 Installation
26 |
27 | Execute:
28 |
29 | ```bash
30 | $ go install github.com/mkchoi212/fac@latest
31 | ```
32 |
33 | Or using [Homebrew 🍺](https://brew.sh)
34 |
35 | ```bash
36 | brew tap mkchoi212/fac https://github.com/mkchoi212/fac.git
37 | brew install fac
38 | ```
39 |
40 | ## 🔧 Using
41 |
42 | `fac` operates much like `git add -p` . It has a prompt input at the bottom of the screen where the user inputs various commands.
43 |
44 | The commands have been preset to the following specifications
45 |
46 | | Keybinding | Description |
47 | | ------------------------------- | -------------------- |
48 | | w | show more lines up |
49 | | s | show more lines down |
50 | | a | use local version |
51 | | d | use incoming version |
52 | | e | manually edit code |
53 | | j | scroll down |
54 | | k | scroll up |
55 | | v | [v]iew orientation |
56 | | n | [n]ext conflict |
57 | | p | [p]revious conflict |
58 | | h, ? | [h]elp |
59 | | q, Ctrl+c | [q]uit |
60 |
61 | ```
62 | [w,a,s,d,?] >> [INPUT HERE]
63 | ```
64 |
65 | ### ⚙️ Customization
66 |
67 | The above key-bindings and various behaviors can be altered by creating a `.fac.yml` file in your home directory.
68 | Please refer to the [configuration README.md](./binding/README.md) for more information.
69 |
70 | ## ✋ Contributing
71 |
72 | This is an open source project so feel free to contribute by
73 |
74 | - Opening an [issue](https://github.com/mkchoi212/fac/issues/new)
75 | - Sending me feedback via [email](mailto://mkchoi212@icloud.com)
76 | - Or [tweet](https://twitter.com/Bananamlkshake2) at me!
77 |
78 | ## 👮 License
79 |
80 | See [License](./LICENSE)
81 |
--------------------------------------------------------------------------------
/assets/banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mkchoi212/fac/d232b05149564701ca3a21cd1a07be2540266cb2/assets/banner.png
--------------------------------------------------------------------------------
/assets/banner.sketch:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mkchoi212/fac/d232b05149564701ca3a21cd1a07be2540266cb2/assets/banner.sketch
--------------------------------------------------------------------------------
/assets/doc/convert-to-man:
--------------------------------------------------------------------------------
1 | #!/bin/sh -e
2 |
3 | # The MIT License (MIT)
4 |
5 | # Copyright (c) 2015 Andrew Gallant
6 |
7 | # Permission is hereby granted, free of charge, to any person obtaining a copy
8 | # of this software and associated documentation files (the "Software"), to deal
9 | # in the Software without restriction, including without limitation the rights
10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11 | # copies of the Software, and to permit persons to whom the Software is
12 | # furnished to do so, subject to the following conditions:
13 |
14 | # The above copyright notice and this permission notice shall be included in
15 | # all copies or substantial portions of the Software.
16 |
17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23 | # THE SOFTWARE.
24 |
25 | pandoc -s -t man fac.1.md -o fac.1
26 | sed -i.bak 's/\.TH.*/.TH "fac" "1"/g' fac.1
27 | rm -f fac.1.bak # BSD `sed` requires the creation of a back-up file
28 |
--------------------------------------------------------------------------------
/assets/doc/fac.1:
--------------------------------------------------------------------------------
1 | .\" Automatically generated by Pandoc 2.2.1
2 | .\"
3 | .TH "fac" "1"
4 | .hy
5 | .SH NAME
6 | .PP
7 | fac \- tool for resolving git merge conflicts
8 | .SH SYNOPSIS
9 | .PP
10 | \f[B]fac \-h | \-help\f[]
11 | .PD 0
12 | .P
13 | .PD
14 | \f[B]fac \-version\f[]
15 | .PD 0
16 | .P
17 | .PD
18 | .SH DESCRIPTION
19 | .PP
20 | Fix All Conflicts (fac) aims to make resolving git(1) merge conflicts
21 | easier.
22 | It provides an ncurses\-based based terminal user interface to resolve
23 | conflicts interactively.
24 | .PP
25 | The UI is split into three panes.
26 | Two show the versions of content in conflict.
27 | The third is a sidebar that show the queue and actions that can be taken
28 | to resolve the conflict.
29 | .SH USAGE
30 | .PP
31 | fac operates much like \f[C]git\ add\ \-p\f[].
32 | It has a prompt for input at the bottom of the screen where the various
33 | commands are entered.
34 | .PP
35 | The commands have been preset to the following specifications:
36 | .TP
37 | .B \f[B]w\f[]
38 | show more lines up
39 | .RS
40 | .RE
41 | .TP
42 | .B \f[B]s\f[]
43 | show more lines down
44 | .RS
45 | .RE
46 | .TP
47 | .B \f[B]a\f[]
48 | use local version
49 | .RS
50 | .RE
51 | .TP
52 | .B \f[B]d\f[]
53 | use incoming version
54 | .RS
55 | .RE
56 | .TP
57 | .B \f[B]e\f[]
58 | manually edit code
59 | .RS
60 | .RE
61 | .TP
62 | .B \f[B]j\f[]
63 | scroll down
64 | .RS
65 | .RE
66 | .TP
67 | .B \f[B]k\f[]
68 | scroll up
69 | .RS
70 | .RE
71 | .TP
72 | .B \f[B]v\f[]
73 | [v]iew orientation
74 | .RS
75 | .RE
76 | .TP
77 | .B \f[B]n\f[]
78 | [n]ext conflict
79 | .RS
80 | .RE
81 | .TP
82 | .B \f[B]p\f[]
83 | [p]revious conflict
84 | .RS
85 | .RE
86 | .TP
87 | .B \f[B]h | ?\f[]
88 | [h]elp
89 | .RS
90 | .RE
91 | .TP
92 | .B \f[B]q | Ctrl+c\f[]
93 | [q]uit
94 | .RS
95 | .RE
96 | .PP
97 | The movement controls have been derived from both the world of gamers
98 | (WASD) and vi(1) users (HJKL).
99 | To customize these key bindings, please refer to the next section.
100 | .SH CUSTOMIZATION
101 | .PP
102 | Program behavior can be customized by creating a \f[C]$HOME/.fac.yml\f[]
103 | with the below variables.
104 | .SS BEHAVIOR
105 | .TP
106 | .B \f[B]cont_eval\f[]
107 | evaluate commands without pressing ENTER
108 | .RS
109 | .RE
110 | .SS KEY BINDINGS
111 | .TP
112 | .B \f[B]select_local\f[]
113 | select local version
114 | .RS
115 | .RE
116 | .TP
117 | .B \f[B]select_incoming\f[]
118 | select incoming version
119 | .RS
120 | .RE
121 | .TP
122 | .B \f[B]toggle_view\f[]
123 | toggle to horizontal | vertical view
124 | .RS
125 | .RE
126 | .TP
127 | .B \f[B]show_up\f[]
128 | show more lines above
129 | .RS
130 | .RE
131 | .TP
132 | .B \f[B]show_down\f[]
133 | show more lines below
134 | .RS
135 | .RE
136 | .TP
137 | .B \f[B]scroll_up\f[]
138 | scroll up
139 | .RS
140 | .RE
141 | .TP
142 | .B \f[B]scroll_down\f[]
143 | scroll down
144 | .RS
145 | .RE
146 | .TP
147 | .B \f[B]edit\f[]
148 | manually edit code chunk
149 | .RS
150 | .RE
151 | .TP
152 | .B \f[B]next\f[]
153 | go to next conflict
154 | .RS
155 | .RE
156 | .TP
157 | .B \f[B]previous\f[]
158 | go to previous conflict
159 | .RS
160 | .RE
161 | .TP
162 | .B \f[B]quit\f[]
163 | quit application
164 | .RS
165 | .RE
166 | .TP
167 | .B \f[B]help\f[]
168 | display help in side bar
169 | .RS
170 | .RE
171 | .SH COPYRIGHT
172 | .PP
173 | Copyright (c) 2018 Mike JS.
174 | Choi
175 | .PP
176 | Permission is hereby granted, free of charge, to any person obtaining a
177 | copy of this software and associated documentation files (the
178 | \[lq]Software\[rq]), to deal in the Software without restriction,
179 | including without limitation the rights to use, copy, modify, merge,
180 | publish, distribute, sublicense, and/or sell copies of the Software, and
181 | to permit persons to whom the Software is furnished to do so, subject to
182 | the following conditions:
183 | .PP
184 | The above copyright notice and this permission notice shall be included
185 | in all copies or substantial portions of the Software.
186 | .PP
187 | THE SOFTWARE IS PROVIDED \[lq]AS IS\[rq], WITHOUT WARRANTY OF ANY KIND,
188 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
189 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
190 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
191 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
192 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
193 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
194 | .SH AUTHOR
195 | .PP
196 | Mike JS.
197 | Choi
198 | .PD 0
199 | .P
200 | .PD
201 | shogo\-ma
202 | .PP
203 | Please send bug reports or comments to
204 | .
205 | .PD 0
206 | .P
207 | .PD
208 | For more information, see the homepage at
209 | .
210 |
--------------------------------------------------------------------------------
/assets/doc/fac.1.md:
--------------------------------------------------------------------------------
1 | # NAME
2 |
3 | fac - tool for resolving git merge conflicts
4 |
5 | # SYNOPSIS
6 |
7 | **fac -h | -help**\
8 | **fac -version**\
9 |
10 | # DESCRIPTION
11 |
12 | Fix All Conflicts (fac) aims to make resolving git(1) merge conflicts easier.
13 | It provides an ncurses-based based terminal user interface to resolve conflicts
14 | interactively.
15 |
16 | The UI is split into three panes. Two show the versions of content in conflict.
17 | The third is a sidebar that show the queue and actions that can be taken to resolve the conflict.
18 |
19 | # USAGE
20 |
21 | fac operates much like `git add -p`. It has a prompt for input at the bottom of
22 | the screen where the various commands are entered.
23 |
24 | The commands have been preset to the following specifications:
25 |
26 | **w**
27 | : show more lines up
28 |
29 | **s**
30 | : show more lines down
31 |
32 | **a**
33 | : use local version
34 |
35 | **d**
36 | : use incoming version
37 |
38 | **e**
39 | : manually edit code
40 |
41 | **j**
42 | : scroll down
43 |
44 | **k**
45 | : scroll up
46 |
47 | **v**
48 | : [v]iew orientation
49 |
50 | **n**
51 | : [n]ext conflict
52 |
53 | **p**
54 | : [p]revious conflict
55 |
56 | **h | ?**
57 | : [h]elp
58 |
59 | **q | Ctrl+c**
60 | : [q]uit
61 |
62 | The movement controls have been derived from both the world of gamers (WASD)
63 | and vi(1) users (HJKL). To customize these key bindings, please refer to the next section.
64 |
65 | # CUSTOMIZATION
66 |
67 | Program behavior can be customized by creating a `$HOME/.fac.yml` with the below variables.
68 |
69 | ## BEHAVIOR
70 |
71 | **cont_eval**
72 | : evaluate commands without pressing ENTER
73 |
74 | ## KEY BINDINGS
75 |
76 | **select_local**
77 | : select local version
78 |
79 | **select_incoming**
80 | : select incoming version
81 |
82 | **toggle_view**
83 | : toggle to horizontal | vertical view
84 |
85 | **show_up**
86 | : show more lines above
87 |
88 | **show_down**
89 | : show more lines below
90 |
91 | **scroll_up**
92 | : scroll up
93 |
94 | **scroll_down**
95 | : scroll down
96 |
97 | **edit**
98 | : manually edit code chunk
99 |
100 | **next**
101 | : go to next conflict
102 |
103 | **previous**
104 | : go to previous conflict
105 |
106 | **quit**
107 | : quit application
108 |
109 | **help**
110 | : display help in side bar
111 |
112 | # COPYRIGHT
113 |
114 | Copyright (c) 2018 Mike JS. Choi
115 |
116 | Permission is hereby granted, free of charge, to any person obtaining a copy
117 | of this software and associated documentation files (the "Software"), to deal
118 | in the Software without restriction, including without limitation the rights
119 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
120 | copies of the Software, and to permit persons to whom the Software is
121 | furnished to do so, subject to the following conditions:
122 |
123 | The above copyright notice and this permission notice shall be included in all
124 | copies or substantial portions of the Software.
125 |
126 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
127 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
128 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
129 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
130 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
131 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
132 | SOFTWARE.
133 |
134 | # AUTHOR
135 |
136 | Mike JS. Choi \
137 | shogo-ma
138 |
139 | Please send bug reports or comments to .\
140 | For more information, see the homepage at .
141 |
142 |
--------------------------------------------------------------------------------
/assets/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mkchoi212/fac/d232b05149564701ca3a21cd1a07be2540266cb2/assets/screenshot.png
--------------------------------------------------------------------------------
/binding/.fac.yml:
--------------------------------------------------------------------------------
1 | erroneous content
--------------------------------------------------------------------------------
/binding/README.md:
--------------------------------------------------------------------------------
1 | # HOW TO: Custom Configuration
2 |
3 | ## .fac.yml
4 |
5 | You can configure fac by creating a `.fac.yml` in your `$HOME` directory. With the file in place, the following paramters can be tweaked:
6 |
7 | ```yml
8 | select_local: a
9 | select_incoming: b
10 | toggle_view: c
11 | show_up: d
12 | show_down: e
13 | scroll_up: f
14 | scroll_down: g
15 | edit: h
16 | next: i
17 | previous: j
18 | quit: k
19 | help: l
20 |
21 | # Set to `true` to skip having to press enter when entering commands
22 | cont_eval: true
23 | ```
24 |
25 | ## 👨⚖️👩⚖️ Rules
26 |
27 | When parsing `.fac.yml`, fac enforces *three rules.*
28 |
29 | ### 1. Invalid key-binding keys
30 |
31 | ```yml
32 | # WRONG
33 | # Warning: Invalid key: "foobar" will be ignored
34 | foobar: f
35 | ```
36 |
37 | ### 2. Multi-character key-mappings
38 |
39 | ```yml
40 | # WRONG
41 | # Warning: Illegal multi-character mapping: "local" will be interpreted as 'l'
42 | select_local: local
43 |
44 | # CORRECT
45 | select_local: l
46 | ```
47 |
48 | ### 3. Duplicate key-mappings
49 |
50 | ```yml
51 | # WRONG
52 | # Fatal: Duplicate key-mapping: "scroll_up, show_up" are all represented by 'u'
53 | show_up: u
54 | scroll_up: u
55 |
56 | # CORRECT
57 | show_up: u
58 | scroll_up: k
59 | ```
60 |
61 | ### 4. Mapping keys
62 |
63 | ```yml
64 | # WRONG
65 | # Warning: yaml: mapping keys are not allowed in this context
66 | help: ?
67 |
68 | # CORRECT
69 | help: "?"
70 | ```
71 |
--------------------------------------------------------------------------------
/binding/binding.go:
--------------------------------------------------------------------------------
1 | package binding
2 |
3 | import (
4 | "bytes"
5 | "errors"
6 | "fmt"
7 | "io/ioutil"
8 | "os/user"
9 | "sort"
10 | "strings"
11 | "time"
12 |
13 | "github.com/mkchoi212/fac/color"
14 | yaml "gopkg.in/yaml.v2"
15 | )
16 |
17 | var currentUser = user.Current
18 |
19 | // Binding represents the user's key binding configuration
20 | type Binding map[string]string
21 |
22 | // Following constants represent all the actions available to the user
23 | // The string literals are used to retrieve values from `Binding` and
24 | // when writing/reading from .fac.yml
25 | const (
26 | SelectLocal = "select_local"
27 | SelectIncoming = "select_incoming"
28 | ToggleViewOrientation = "toggle_view"
29 | ShowLinesUp = "show_up"
30 | ShowLinesDown = "show_down"
31 | ScrollUp = "scroll_up"
32 | ScrollDown = "scroll_down"
33 | EditCode = "edit"
34 | NextConflict = "next"
35 | PreviousConflict = "previous"
36 | QuitApplication = "quit"
37 | ShowHelp = "help"
38 | ContinuousEvaluation = "cont_eval"
39 | DefaultContextLines = "default_context_lines"
40 | )
41 |
42 | // defaultBinding is used when the user has not specified any of the
43 | // available actions via `.fac.yml`
44 | var defaultBinding = Binding{
45 | SelectLocal: "a",
46 | SelectIncoming: "d",
47 | ToggleViewOrientation: "v",
48 | ShowLinesUp: "w",
49 | ShowLinesDown: "s",
50 | ScrollUp: "j",
51 | ScrollDown: "k",
52 | EditCode: "e",
53 | NextConflict: "n",
54 | PreviousConflict: "p",
55 | QuitApplication: "q",
56 | ShowHelp: "h",
57 | ContinuousEvaluation: "false",
58 | DefaultContextLines: "2",
59 | }
60 |
61 | // LoadSettings looks for a user specified key-binding settings file - `$HOME/.fac.yml`
62 | // and returns a map representation of the file
63 | // It also looks for errors, and ambiguities within the file and notifies the user of them
64 | func LoadSettings() (b Binding, err error) {
65 | warnings := []string{}
66 |
67 | b, ok := parseSettings()
68 | if ok != nil {
69 | warnings = append(warnings, ok.Error())
70 | }
71 |
72 | verificationWarnings, fatals := b.verify()
73 | warnings = append(warnings, verificationWarnings...)
74 |
75 | if len(warnings) != 0 {
76 | fmt.Println(color.Yellow(color.Regular, "⚠️ %d infraction(s) detected in .fac.yml", len(warnings)))
77 | for _, msg := range warnings {
78 | fmt.Println(color.Yellow(color.Regular, "%s", msg))
79 | }
80 | fmt.Println()
81 | }
82 |
83 | if len(fatals) != 0 {
84 | var errorMsg bytes.Buffer
85 | errorMsg.WriteString(color.Red(color.Regular, "🚫 %d unrecoverable error(s) detected in .fac.yml", len(fatals)))
86 | for _, msg := range fatals {
87 | errorMsg.WriteString(color.Red(color.Regular, "\n%s", msg))
88 | }
89 | return nil, errors.New(errorMsg.String())
90 | }
91 |
92 | if len(warnings) != 0 {
93 | time.Sleep(time.Duration(2) * time.Second)
94 | }
95 |
96 | b.consolidate()
97 | return
98 | }
99 |
100 | // parseSettings looks for `$HOME/.fac.yml` and parses it into a `Binding` value
101 | // If the file does not exist, it returns the `defaultBinding`
102 | func parseSettings() (Binding, error) {
103 | userBinding := make(Binding)
104 |
105 | usr, err := currentUser()
106 | if err != nil {
107 | return defaultBinding, err
108 | }
109 |
110 | // Read config file
111 | f, err := ioutil.ReadFile(usr.HomeDir + "/.fac.yml")
112 | if err != nil {
113 | return defaultBinding, err
114 | }
115 |
116 | // Parse config file
117 | if err = yaml.Unmarshal(f, &userBinding); err != nil {
118 | return defaultBinding, err
119 | }
120 |
121 | return userBinding, nil
122 | }
123 |
124 | // consolidate takes the user's key-binding settings and fills the missings key-binds
125 | // with the default key-binding values
126 | func (b Binding) consolidate() {
127 | for key, defaultValue := range defaultBinding {
128 | userValue, ok := b[key]
129 |
130 | if !ok || userValue == "" {
131 | b[key] = defaultValue
132 | } else if len(userValue) > 1 && userValue != "false" && userValue != "true" {
133 | b[key] = string(userValue[0])
134 | }
135 | }
136 | }
137 |
138 | // verify looks through the user's key-binding settings and looks for any infractions such as..
139 | // 1. Invalid/ignored key-binding keys
140 | // 2. Multi-character key-mappings (except for `cont_eval`)
141 | // 3. Duplicate key-mappings
142 | func (b Binding) verify() (warnings []string, fatals []string) {
143 | bindTable := map[string][]string{}
144 |
145 | for k, v := range b {
146 | // Check for "1. Invalid/ignored key-binding keys"
147 | if _, ok := defaultBinding[k]; !ok {
148 | warnings = append(warnings, fmt.Sprintf("Invalid key: \"%s\" will be ignored", k))
149 | delete(b, k)
150 | continue
151 | }
152 |
153 | // Check for "2. Multi-character key-mappings"
154 | if k == ContinuousEvaluation && v != "false" && v != "true" {
155 | fatals = append(fatals, fmt.Sprintf("Invalid value: value for key '%s' must either be true or false", ContinuousEvaluation))
156 | continue
157 | } else if len(v) > 1 && k != ContinuousEvaluation {
158 | abbreviated := string(v[0])
159 | warnings = append(warnings, fmt.Sprintf("Illegal multi-character mapping: \"%s\" will be interpreted as '%s'", v, abbreviated))
160 | bindTable[abbreviated] = append(bindTable[abbreviated], k)
161 | } else {
162 | bindTable[v] = append(bindTable[v], k)
163 | }
164 | }
165 |
166 | // Check for "3. Duplicate key-mappings"
167 | for k, v := range bindTable {
168 | if len(v) > 1 {
169 | sort.Strings(v)
170 | duplicateValues := strings.Join(v, ", ")
171 | fatals = append(fatals, fmt.Sprintf("Duplicate key-mapping: \"%s\" are all represented by '%s'", duplicateValues, k))
172 | }
173 | }
174 |
175 | return
176 | }
177 |
178 | // Summary returns a short summary of the provided `Binding`
179 | // and is used as the helpful string displayed by the user's input field
180 | // e.g. "[w,a,s,d,e,?] >>"
181 | func (b Binding) Summary() string {
182 | targetKeys := []string{
183 | b[ShowLinesUp],
184 | b[SelectLocal],
185 | b[ShowLinesDown],
186 | b[SelectIncoming],
187 | b[EditCode],
188 | }
189 | return "[" + strings.Join(targetKeys, ",") + ",?] >>"
190 | }
191 |
192 | // Help returns a help string that is displayed on the right panel of the UI
193 | // It should provided an overall summary of all available key options
194 | func (b Binding) Help() string {
195 | format := `
196 | %s - use local version
197 | %s - use incoming version
198 | %s - manually edit code
199 |
200 | %s - show more lines up
201 | %s - show more lines down
202 | %s - scroll up
203 | %s - scroll down
204 |
205 | %s - view orientation
206 | %s - next conflict
207 | %s - previous conflict
208 |
209 | %s | ? - help
210 | %s | Ctrl+C - quit
211 | `
212 |
213 | return fmt.Sprintf(format, b[SelectLocal], b[SelectIncoming], b[EditCode],
214 | b[ShowLinesUp], b[ShowLinesDown],
215 | b[ScrollUp], b[ScrollDown], b[ToggleViewOrientation], b[NextConflict], b[PreviousConflict],
216 | b[ShowHelp], b[QuitApplication])
217 | }
218 |
--------------------------------------------------------------------------------
/binding/binding_test.go:
--------------------------------------------------------------------------------
1 | package binding
2 |
3 | import (
4 | "errors"
5 | "os"
6 | "os/user"
7 | "sort"
8 | "strings"
9 | "testing"
10 |
11 | "gopkg.in/yaml.v2"
12 |
13 | "github.com/mkchoi212/fac/testhelper"
14 | )
15 |
16 | var tests = []struct {
17 | settings Binding
18 | expected Binding
19 | warnings []string
20 | fatals []string
21 | }{
22 | {
23 | settings: Binding{
24 | "foobar": "a",
25 | "hello": "b",
26 | },
27 | expected: defaultBinding,
28 | warnings: []string{
29 | "Invalid key: \"foobar\" will be ignored",
30 | "Invalid key: \"hello\" will be ignored",
31 | },
32 | fatals: nil,
33 | },
34 | {
35 | settings: Binding{
36 | SelectLocal: "i",
37 | SelectIncoming: "incoming",
38 | },
39 | expected: nil,
40 | warnings: []string{
41 | "Illegal multi-character mapping: \"incoming\" will be interpreted as 'i'",
42 | },
43 | fatals: []string{
44 | "Duplicate key-mapping: \"select_incoming, select_local\" are all represented by 'i'",
45 | },
46 | },
47 | {
48 | settings: Binding{
49 | ContinuousEvaluation: "kinda",
50 | SelectIncoming: "i",
51 | SelectLocal: "i",
52 | },
53 | expected: nil,
54 | warnings: nil,
55 | fatals: []string{
56 | "Invalid value: value for key 'cont_eval' must either be true or false",
57 | "Duplicate key-mapping: \"select_incoming, select_local\" are all represented by 'i'",
58 | },
59 | },
60 | {
61 | settings: Binding{
62 | ShowLinesDown: "d",
63 | ShowLinesUp: "u",
64 | },
65 | expected: Binding{
66 | ShowLinesDown: "d",
67 | ShowLinesUp: "u",
68 | },
69 | warnings: nil,
70 | fatals: nil,
71 | },
72 | }
73 |
74 | func TestLoadSettings(t *testing.T) {
75 | currentUser = func() (*user.User, error) {
76 | return &user.User{HomeDir: "."}, nil
77 | }
78 |
79 | for _, test := range tests {
80 | // Create dummy yml file with content
81 | f, err := os.Create(".fac.yml")
82 | testhelper.Ok(t, err)
83 | data, _ := yaml.Marshal(&test.settings)
84 | f.WriteString(string(data))
85 | f.Close()
86 |
87 | output, err := LoadSettings()
88 | if len(test.fatals) == 0 {
89 | testhelper.Ok(t, err)
90 | test.expected.consolidate()
91 | testhelper.Equals(t, test.expected, output)
92 | } else {
93 | testhelper.Assert(t, err != nil, "%v should fail", test)
94 | for _, msg := range test.fatals {
95 | testhelper.Assert(t, strings.Contains(err.Error(), msg), "Error message should contain message %s", msg)
96 | }
97 | }
98 | }
99 | }
100 |
101 | func TestParseSettings(t *testing.T) {
102 | // Test with invalid currentUser
103 | currentUser = func() (*user.User, error) {
104 | return nil, errors.New("Could not find current user")
105 | }
106 | binding, _ := parseSettings()
107 | testhelper.Equals(t, defaultBinding, binding)
108 |
109 | // Test with invalid directory
110 | currentUser = func() (*user.User, error) {
111 | return &user.User{HomeDir: "foobar"}, nil
112 | }
113 | binding, _ = parseSettings()
114 | testhelper.Equals(t, defaultBinding, binding)
115 |
116 | // Test with valid directory with empty file
117 | currentUser = func() (*user.User, error) {
118 | return &user.User{HomeDir: "."}, nil
119 | }
120 | f, err := os.Create(".fac.yml")
121 | testhelper.Ok(t, err)
122 | defer f.Close()
123 |
124 | binding, _ = parseSettings()
125 | testhelper.Equals(t, 0, len(binding))
126 |
127 | // Test valid directory with erroneous content
128 | f.WriteString("erroneous content")
129 | binding, _ = parseSettings()
130 | testhelper.Equals(t, defaultBinding, binding)
131 | }
132 |
133 | func TestVerify(t *testing.T) {
134 | for _, test := range tests {
135 | warnings, fatals := test.settings.verify()
136 | sort.Strings(warnings)
137 | sort.Strings(test.warnings)
138 | testhelper.Equals(t, test.warnings, warnings)
139 | testhelper.Equals(t, test.fatals, fatals)
140 | }
141 | }
142 |
143 | func TestSummary(t *testing.T) {
144 | summary := defaultBinding.Summary()
145 | testhelper.Equals(t, summary, "[w,a,s,d,e,?] >>")
146 | }
147 |
148 | func TestHelp(t *testing.T) {
149 | helpMsg := defaultBinding.Help()
150 | testhelper.Assert(t, len(helpMsg) != 0, "Help message should not be of length 0")
151 | }
152 |
153 | func TestConsolidate(t *testing.T) {
154 | for _, test := range tests {
155 | test.settings.consolidate()
156 |
157 | for k := range defaultBinding {
158 | _, ok := test.settings[k]
159 | testhelper.Equals(t, true, ok)
160 | }
161 | }
162 | }
163 |
--------------------------------------------------------------------------------
/color/color.go:
--------------------------------------------------------------------------------
1 | package color
2 |
3 | import (
4 | "fmt"
5 | )
6 |
7 | // Defines the color of the text
8 | const (
9 | FgBlack = iota
10 | FgRed
11 | FgGreen
12 | FgYellow
13 | FgBlue
14 | FgPurple
15 | )
16 |
17 | // Defines the style of the text
18 | const (
19 | Regular = iota + 1
20 | Light
21 | Highlight
22 | Underline
23 | )
24 |
25 | // Apply applies a `color` and a `style` to the provided `format`
26 | // It does this by using ANSI color codes
27 | func Apply(color int, style int, format string, a ...interface{}) string {
28 | var str string
29 | if len(a) == 0 {
30 | str = format
31 | } else {
32 | str = fmt.Sprintf(format, a...)
33 | }
34 | return fmt.Sprintf("\033[3%d;%dm%s\033[0m", color, style, str)
35 | }
36 |
37 | // Red returns a string with red foreground
38 | func Red(style int, format string, a ...interface{}) string {
39 | return Apply(FgRed, style, format, a...)
40 | }
41 |
42 | // Yellow returns a string with red foreground
43 | func Yellow(style int, format string, a ...interface{}) string {
44 | return Apply(FgYellow, style, format, a...)
45 | }
46 |
47 | // Green returns a string with green foreground
48 | func Green(style int, format string, a ...interface{}) string {
49 | return Apply(FgGreen, style, format, a...)
50 | }
51 |
52 | // Blue returns a string with blue foreground
53 | func Blue(style int, format string, a ...interface{}) string {
54 | return Apply(FgBlue, style, format, a...)
55 | }
56 |
57 | // Black returns a string with black foreground
58 | func Black(style int, format string, a ...interface{}) string {
59 | return Apply(FgBlack, style, format, a...)
60 | }
61 |
--------------------------------------------------------------------------------
/color/color_test.go:
--------------------------------------------------------------------------------
1 | package color
2 |
3 | import (
4 | "os"
5 | "testing"
6 |
7 | "github.com/mkchoi212/fac/testhelper"
8 | )
9 |
10 | // Setup tests
11 | var tests = []struct {
12 | color func(style int, format string, a ...interface{}) string
13 | input []string
14 | expected string
15 | }{
16 | {Black, []string{"%s", "foobar"}, "\033[30;1mfoobar[0m"},
17 | {Red, []string{"%s %s", "foobar", "hey"}, "\033[31;1mfoobar hey[0m"},
18 | {Green, []string{"%s", ""}, "\033[32;1m[0m"},
19 | {Blue, []string{"foobar"}, "\033[34;1mfoobar[0m"},
20 | {Yellow, []string{"foobar"}, "\033[33;1mfoobar[0m"},
21 | }
22 |
23 | func TestColors(t *testing.T) {
24 | // Redirect stdout
25 | oldStdout := os.Stdout
26 | _, w, err := os.Pipe()
27 | testhelper.Ok(t, err)
28 |
29 | os.Stdout = w
30 |
31 | // Restore old stdout
32 | defer func() {
33 | w.Close()
34 | os.Stdout = oldStdout
35 | }()
36 |
37 | // Check output
38 | var out string
39 | for _, test := range tests {
40 | if len(test.input) == 1 {
41 | // With formatter
42 | out = test.color(Regular, test.input[0])
43 | } else {
44 | // Without formatter
45 | s := make([]interface{}, len(test.input)-1)
46 | for i, v := range test.input[1:] {
47 | s[i] = v
48 | }
49 | out = test.color(Regular, test.input[0], s...)
50 | }
51 |
52 | testhelper.Equals(t, test.expected, out)
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/conflict/command.go:
--------------------------------------------------------------------------------
1 | package conflict
2 |
3 | import (
4 | "bytes"
5 | "errors"
6 | "os/exec"
7 | "strings"
8 | "syscall"
9 | )
10 |
11 | var execCommand = exec.Command
12 | var argsEnvFlag = "GO_MOCK_PROCESS_ARGS"
13 |
14 | // run runs the given command with arguments and returns the output
15 | // Refer to https://stackoverflow.com/questions/10385551/get-exit-code-go
16 | func run(name string, dir string, args ...string) (stdout string, stderr string, exitCode int) {
17 | var outbuf, errbuf bytes.Buffer
18 | cmd := execCommand(name, args...)
19 | cmd.Dir = dir
20 |
21 | // Save config for testing purposes
22 | cmd.Env = append(cmd.Env, argsEnvFlag+"="+strings.Join(args, ","))
23 |
24 | cmd.Stdout = &outbuf
25 | cmd.Stderr = &errbuf
26 |
27 | err := cmd.Run()
28 | stdout = outbuf.String()
29 | stderr = errbuf.String()
30 |
31 | if err != nil {
32 | if exitError, ok := err.(*exec.ExitError); ok {
33 | ws := exitError.Sys().(syscall.WaitStatus)
34 | exitCode = ws.ExitStatus()
35 | } else {
36 | // This will happen (in OSX) if `name` is not available in $PATH,
37 | // in this situation, exit code could not be get, and stderr will be
38 | // empty string very likely, so we use the default fail code, and format err
39 | // to string and set to stderr
40 | exitCode = 1
41 | if stderr == "" {
42 | stderr = err.Error()
43 | }
44 | }
45 | } else {
46 | // Success and exitCode should be 0
47 | ws := cmd.ProcessState.Sys().(syscall.WaitStatus)
48 | exitCode = ws.ExitStatus()
49 | }
50 | return
51 | }
52 |
53 | // conflictedFiles returns a list of conflicted files
54 | func conflictedFiles(cwd string) ([]string, error) {
55 | stdout, stderr, _ := run("git", cwd, "--no-pager", "diff", "--name-only", "--diff-filter=U")
56 |
57 | if len(stderr) != 0 {
58 | return nil, errors.New(stderr)
59 | }
60 |
61 | stdout = strings.TrimSuffix(stdout, "\n")
62 | if stdout == "" {
63 | return []string{}, nil
64 | }
65 |
66 | return strings.Split(stdout, "\n"), nil
67 | }
68 |
69 | // topLevelPath finds the top level path of the current git repository
70 | func topLevelPath(cwd string) (string, error) {
71 | stdout, stderr, _ := run("git", cwd, "rev-parse", "--show-toplevel")
72 |
73 | if len(stderr) != 0 {
74 | return "", errors.New(stderr)
75 | }
76 |
77 | return string(strings.Split(stdout, "\n")[0]), nil
78 | }
79 |
--------------------------------------------------------------------------------
/conflict/command_test.go:
--------------------------------------------------------------------------------
1 | package conflict
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "os/exec"
7 | "strconv"
8 | "testing"
9 |
10 | "github.com/mkchoi212/fac/testhelper"
11 | )
12 |
13 | var mockValidDirectory = "/"
14 | var mockInvalidDirectory = "/hello/world"
15 |
16 | var commands = []struct {
17 | command string
18 | ok bool
19 | }{
20 | {"time", true},
21 | {"ls", true},
22 | {"foobar", false},
23 | }
24 |
25 | func TestHelperProcess(t *testing.T) {
26 | if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
27 | return
28 | }
29 |
30 | args := os.Getenv(argsEnvFlag)
31 |
32 | // Purposely fail test
33 | if args == "false" {
34 | fmt.Fprintf(os.Stderr, "Mock exec: Command tragically failed")
35 | os.Exit(1)
36 | }
37 |
38 | // TopLevelPath arguments
39 | if args == "rev-parse,--show-toplevel" {
40 | fmt.Fprintf(os.Stdout, "testdata")
41 | os.Exit(0)
42 | }
43 |
44 | if args == "--no-pager,diff,--name-only,--diff-filter=U" {
45 | fmt.Fprintf(os.Stdout, "lorem_ipsum\nassets/README.md\n")
46 | os.Exit(0)
47 | }
48 |
49 | fmt.Fprintf(os.Stdout, "Mock exec: Command succeeded")
50 | os.Exit(0)
51 | }
52 |
53 | // Allows us to mock exec.Command, thanks to
54 | // https://npf.io/2015/06/testing-exec-command/
55 | func mockExecCommand(command string, args ...string) *exec.Cmd {
56 | cs := []string{"-test.run=TestHelperProcess", "--", command}
57 | cs = append(cs, args...)
58 | cmd := exec.Command(os.Args[0], cs...)
59 | cmd.Env = append(cmd.Env, "GO_WANT_HELPER_PROCESS=1")
60 | return cmd
61 | }
62 |
63 | func TestRun(t *testing.T) {
64 | execCommand = mockExecCommand
65 | defer func() { execCommand = exec.Command }()
66 |
67 | for _, test := range commands {
68 | stdout, stderr, exitCode := run(test.command, ".", strconv.FormatBool(test.ok))
69 |
70 | if test.ok {
71 | testhelper.Assert(t, exitCode == 0, "expected no errors but got %s", stderr)
72 | } else {
73 | testhelper.Assert(t, exitCode != 0, "expected errors but got %s", stdout)
74 | }
75 | }
76 | }
77 |
78 | func TestConflictedFiles(t *testing.T) {
79 | execCommand = mockExecCommand
80 | defer func() { execCommand = exec.Command }()
81 |
82 | tests := []string{mockValidDirectory, mockInvalidDirectory}
83 | for _, test := range tests {
84 | out, err := conflictedFiles(test)
85 |
86 | if test == mockValidDirectory {
87 | testhelper.Ok(t, err)
88 | } else if test == mockInvalidDirectory {
89 | testhelper.Assert(t, err != nil, "expected errors but got %s", out)
90 | }
91 | }
92 | }
93 |
94 | func TestTopLevelPath(t *testing.T) {
95 | execCommand = mockExecCommand
96 | defer func() { execCommand = exec.Command }()
97 |
98 | tests := []string{mockValidDirectory, mockInvalidDirectory}
99 | for _, test := range tests {
100 | out, err := topLevelPath(test)
101 |
102 | if test == mockValidDirectory {
103 | testhelper.Ok(t, err)
104 | } else if test == mockInvalidDirectory {
105 | testhelper.Assert(t, err != nil, "expected errors but got %s", out)
106 | }
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/conflict/conflict.go:
--------------------------------------------------------------------------------
1 | package conflict
2 |
3 | import (
4 | "bytes"
5 | "errors"
6 | "sort"
7 | "strconv"
8 | "strings"
9 |
10 | "github.com/alecthomas/chroma"
11 | "github.com/alecthomas/chroma/formatters"
12 | "github.com/alecthomas/chroma/lexers"
13 | "github.com/alecthomas/chroma/styles"
14 | "github.com/mkchoi212/fac/color"
15 | )
16 |
17 | // Conflict represents a single conflict that may have occurred
18 | type Conflict struct {
19 | File *File
20 |
21 | Choice int
22 | Start int
23 | Middle int
24 | End int
25 | Diff3 []int
26 |
27 | LocalLines []string
28 | LocalPureLines []string
29 | IncomingLines []string
30 | ColoredLocalLines []string
31 | ColoredIncomingLines []string
32 |
33 | CurrentName string
34 | ForeignName string
35 |
36 | TopPeek int
37 | BottomPeek int
38 | }
39 |
40 | // Represents user's conflict resolution decision
41 | const (
42 | Local = 1
43 | Incoming = 2
44 | )
45 |
46 | // ErrInvalidManualInput is thrown when the manual code editing session
47 | // results in deletion of the conflict markers in the text
48 | var ErrInvalidManualInput = errors.New("Newly edited code is invalid")
49 |
50 | // Valid checks if the parsed conflict has corresponding begin, separator,
51 | // and middle line numbers
52 | func (c *Conflict) Valid() bool {
53 | return c.Start != -1 && c.Middle != 0 && c.End != 0
54 | }
55 |
56 | // Equal checks if two `Conflict`s are equal
57 | func (c Conflict) Equal(c2 *Conflict) bool {
58 | return c.File.AbsolutePath == c2.File.AbsolutePath && c.Start == c2.Start
59 | }
60 |
61 | // Extract extracts lines where conflicts exist
62 | // and corresponding branch names
63 | func (c *Conflict) Extract(lines []string) error {
64 | c.LocalLines = lines[c.Start+1 : c.Middle]
65 | if len(c.Diff3) != 0 {
66 | sort.Ints(c.Diff3)
67 | diff3Barrier := c.Diff3[0]
68 | c.LocalPureLines = lines[c.Start+1 : diff3Barrier]
69 | } else {
70 | c.LocalPureLines = c.LocalLines
71 | }
72 | c.IncomingLines = lines[c.Middle+1 : c.End]
73 | c.CurrentName = strings.Split(lines[c.Start], " ")[1]
74 | c.ForeignName = strings.Split(lines[c.End], " ")[1]
75 | return nil
76 | }
77 |
78 | // Update takes the user's input from an editor and updates the current
79 | // representation of `Conflict`
80 | func (c *Conflict) Update(incoming []string) (err error) {
81 | conflicts, err := GroupConflictMarkers(incoming)
82 | if err != nil || len(conflicts) != 1 {
83 | return ErrInvalidManualInput
84 | }
85 |
86 | updated := conflicts[0]
87 | if err = updated.Extract(incoming); err != nil {
88 | return
89 | }
90 |
91 | updated.File = c.File
92 | if err = updated.HighlightSyntax(); err != nil {
93 | return
94 | }
95 |
96 | c.IncomingLines, c.ColoredIncomingLines = updated.IncomingLines, updated.ColoredIncomingLines
97 | c.LocalLines, c.ColoredLocalLines = updated.LocalLines, updated.ColoredLocalLines
98 | c.LocalPureLines = updated.LocalPureLines
99 | return
100 | }
101 |
102 | // ContextLines sets the TopPeek and BottomPeek peek values
103 | func (c *Conflict) SetContextLines(contextLines string) (err error) {
104 | // Set context lines to show
105 | peek, err := strconv.ParseInt(contextLines, 10, 32)
106 | if err != nil {
107 | return
108 | }
109 | if peek < 0 {
110 | err = errors.New("Invalid context lines, expecting a positive number")
111 | return
112 | }
113 | c.TopPeek = int(peek)
114 | c.BottomPeek = int(peek)
115 | return
116 | }
117 |
118 | // PaddingLines returns top and bottom padding lines based on
119 | // `TopPeek` and `BottomPeek` values
120 | func (c *Conflict) PaddingLines() (topPadding, bottomPadding []string) {
121 | lines := c.File.Lines
122 | start, end := c.Start, c.End
123 |
124 | if c.TopPeek >= start {
125 | c.TopPeek = start
126 | } else if c.TopPeek < 0 {
127 | c.TopPeek = 0
128 | }
129 |
130 | for _, l := range lines[start-c.TopPeek : start] {
131 | topPadding = append(topPadding, color.Black(color.Regular, l))
132 | }
133 |
134 | if c.BottomPeek >= len(lines)-c.End {
135 | c.BottomPeek = len(lines) - c.End
136 | } else if c.BottomPeek < 0 {
137 | c.BottomPeek = 0
138 | }
139 |
140 | for _, l := range lines[end : end+c.BottomPeek] {
141 | bottomPadding = append(bottomPadding, color.Black(color.Regular, l))
142 | }
143 | return
144 | }
145 |
146 | // HighlightSyntax highlights the stored file lines; both local and incoming lines
147 | // The highlighted versions of the lines are stored in Conflict.Colored____Lines
148 | // If the file extension is not supported, no highlights are applied
149 | func (c *Conflict) HighlightSyntax() error {
150 | var lexer chroma.Lexer
151 |
152 | if lexer = lexers.Match(c.File.Name); lexer == nil {
153 | for _, block := range [][]string{c.LocalLines, c.IncomingLines} {
154 | if trial := lexers.Analyse(strings.Join(block, "")); trial != nil {
155 | lexer = trial
156 | break
157 | }
158 | }
159 | }
160 |
161 | if lexer == nil {
162 | lexer = lexers.Fallback
163 | c.ColoredLocalLines = c.LocalLines
164 | c.ColoredIncomingLines = c.IncomingLines
165 | return nil
166 | }
167 |
168 | style := styles.Get("emacs")
169 | formatter := formatters.Get("terminal")
170 |
171 | var it chroma.Iterator
172 | var err error
173 | buf := new(bytes.Buffer)
174 | var colorLine string
175 |
176 | tokenizer:
177 | for i, block := range [][]string{c.LocalLines, c.IncomingLines} {
178 | for _, line := range block {
179 | if identifyStyle(line) == diff3 {
180 | colorLine = color.Red(color.Regular, line)
181 | } else {
182 | if it, err = lexer.Tokenise(nil, line); err != nil {
183 | break tokenizer
184 | }
185 | if err = formatter.Format(buf, style, it); err != nil {
186 | break tokenizer
187 | }
188 | colorLine = buf.String()
189 | }
190 |
191 | if i == 0 {
192 | c.ColoredLocalLines = append(c.ColoredLocalLines, colorLine)
193 | } else {
194 | c.ColoredIncomingLines = append(c.ColoredIncomingLines, colorLine)
195 | }
196 | buf.Reset()
197 | }
198 | }
199 | return err
200 | }
201 |
--------------------------------------------------------------------------------
/conflict/conflict_test.go:
--------------------------------------------------------------------------------
1 | package conflict
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 |
7 | "github.com/mkchoi212/fac/color"
8 | "github.com/mkchoi212/fac/testhelper"
9 | )
10 |
11 | var dummyFile = struct {
12 | lines []string
13 | start int
14 | middle int
15 | end int
16 | diff3 []int
17 | }{
18 | lines: []string{
19 | "<<<<<<< Updated upstream:assets/README.md",
20 | "brew tap mkchoi212/fac https://github.com/mkchoi212/fac.git",
21 | "brew install fac",
22 | "||||||| merged common ancestors",
23 | "brew tap mkchoi212/fac https://github.com/parliament/fac.git",
24 | "brew install fac",
25 | "=======",
26 | "rotten_brew tap childish_funcmonster/facc https://github.com/parliament/facc.git",
27 | "rotten_brew install facc",
28 | ">>>>>>> Stashed changes:README.md",
29 | "hello",
30 | "world",
31 | },
32 | start: 0,
33 | diff3: []int{3},
34 | middle: 5,
35 | end: 9,
36 | }
37 |
38 | func TestValidConflict(t *testing.T) {
39 | invalid := Conflict{
40 | Start: 2,
41 | End: 8,
42 | }
43 | testhelper.Assert(t, !invalid.Valid(), "%s should be invalid", invalid)
44 |
45 | valid := Conflict{
46 | Start: 2,
47 | Middle: 4,
48 | End: 8,
49 | }
50 | testhelper.Assert(t, valid.Valid(), "%s should be valid", invalid)
51 | }
52 |
53 | func TestExtract(t *testing.T) {
54 | c := Conflict{
55 | Start: dummyFile.start,
56 | Middle: dummyFile.middle,
57 | End: dummyFile.end,
58 | Diff3: dummyFile.diff3,
59 | }
60 |
61 | err := c.Extract(dummyFile.lines)
62 | testhelper.Ok(t, err)
63 |
64 | expectedCurrentName := "Updated"
65 | testhelper.Equals(t, expectedCurrentName, c.CurrentName)
66 |
67 | expectedForeignName := "Stashed"
68 | testhelper.Equals(t, expectedForeignName, c.ForeignName)
69 |
70 | expectedIncoming := dummyFile.lines[dummyFile.middle+1 : dummyFile.end]
71 | testhelper.Equals(t, expectedIncoming, c.IncomingLines)
72 |
73 | expectedPureIncoming := dummyFile.lines[dummyFile.start+1 : dummyFile.diff3[0]]
74 | testhelper.Equals(t, expectedPureIncoming, c.LocalPureLines)
75 | }
76 |
77 | func TestUpdate(t *testing.T) {
78 | for _, test := range tests {
79 | // Read "manually written" lines
80 | f := File{AbsolutePath: test.path}
81 | err := f.Read()
82 | testhelper.Ok(t, err)
83 |
84 | // Ignore files with more than one conflicts
85 | // to simulate manual edit
86 | if test.numConflicts > 1 {
87 | continue
88 | }
89 |
90 | // Update empty `Conflict`
91 | c := Conflict{File: &f}
92 | err = c.Update(f.Lines)
93 |
94 | if test.shouldPass {
95 | testhelper.Ok(t, err)
96 | } else {
97 | testhelper.Assert(t, err != nil, "%s should not have passed", f)
98 | }
99 | }
100 | }
101 |
102 | func TestEqual(t *testing.T) {
103 | foo := File{AbsolutePath: "/bin/foo"}
104 | bar := File{AbsolutePath: "/bin/bar"}
105 |
106 | c1, c2 := Conflict{Start: 45, File: &foo}, Conflict{Start: 45, File: &foo}
107 | c3 := Conflict{Start: 45, File: &bar}
108 |
109 | testhelper.Assert(t, c1.Equal(&c2), "%s and %s should be equal", c1, c2)
110 | testhelper.Assert(t, !(c1.Equal(&c3)), "%s and %s should not be equal", c1, c2)
111 | }
112 |
113 | func TestSetContextLines(t *testing.T) {
114 | var testCases = []struct {
115 | in string
116 | expected int
117 | err bool
118 | }{
119 | {"2", 2, false},
120 | {"0", 0, false},
121 | {"-2", 0, true},
122 | {"foo", 0, true},
123 | }
124 |
125 | c := Conflict{}
126 |
127 | for _, tt := range testCases {
128 | if err := c.SetContextLines(tt.in); err != nil {
129 | testhelper.Assert(t, tt.err, "Didn't expect error to be returned, input: %s", tt.in)
130 | }
131 |
132 | testhelper.Assert(t, c.TopPeek == tt.expected, "TopPeek, expected: %s, returned: %s", tt.expected, c.TopPeek)
133 | testhelper.Assert(t, c.BottomPeek == tt.expected, "BottomPeek, expected: %s, returned: %s", tt.expected, c.BottomPeek)
134 | }
135 | }
136 |
137 | func TestPaddingLines(t *testing.T) {
138 | f := File{Lines: dummyFile.lines}
139 | c := Conflict{
140 | Start: dummyFile.start,
141 | End: dummyFile.end,
142 | File: &f,
143 | }
144 |
145 | top, bottom := c.PaddingLines()
146 | testhelper.Assert(t, len(top) == 0 && len(bottom) == 0, "top and bottom peak should initially be 0")
147 |
148 | c.TopPeek--
149 | c.BottomPeek++
150 | top, bottom = c.PaddingLines()
151 | expectedBottom := color.Black(color.Regular, f.Lines[dummyFile.end])
152 |
153 | testhelper.Equals(t, len(top), 0)
154 | testhelper.Equals(t, len(bottom), 1)
155 | testhelper.Equals(t, bottom[0], expectedBottom)
156 | }
157 |
158 | func TestHighlight(t *testing.T) {
159 | for _, test := range tests {
160 | f := File{AbsolutePath: test.path, Name: test.path}
161 | err := f.Read()
162 | testhelper.Ok(t, err)
163 |
164 | conflicts, err := ExtractConflicts(f)
165 | if test.shouldPass {
166 | testhelper.Ok(t, err)
167 | } else {
168 | testhelper.Assert(t, err != nil, "%s is not parsable", f)
169 | }
170 |
171 | for _, c := range conflicts {
172 | _ = c.HighlightSyntax()
173 |
174 | if test.highlightable {
175 | testhelper.Assert(t, !reflect.DeepEqual(c.IncomingLines, c.ColoredIncomingLines), "%s should be highlighted", f)
176 | } else {
177 | testhelper.Equals(t, c.IncomingLines, c.ColoredIncomingLines)
178 | }
179 | }
180 | }
181 | }
182 |
--------------------------------------------------------------------------------
/conflict/file.go:
--------------------------------------------------------------------------------
1 | package conflict
2 |
3 | import (
4 | "bufio"
5 | "io"
6 | "os"
7 | "strings"
8 | )
9 |
10 | // File represents a single file that contains git merge conflicts
11 | type File struct {
12 | AbsolutePath string
13 | Name string
14 | Lines []string
15 | Conflicts []Conflict
16 | }
17 |
18 | // readFile reads all lines of a given file
19 | func (f *File) Read() (err error) {
20 | input, err := os.Open(f.AbsolutePath)
21 | if err != nil {
22 | return
23 | }
24 | defer input.Close()
25 |
26 | r := bufio.NewReader(input)
27 |
28 | for {
29 | data, err := r.ReadBytes('\n')
30 | if err == nil || err == io.EOF {
31 | // gocui currently doesn't support printing \r
32 | line := strings.Replace(string(data), "\r", "", -1)
33 | f.Lines = append(f.Lines, line)
34 | }
35 |
36 | if err != nil {
37 | if err != io.EOF {
38 | return err
39 | }
40 | break
41 | }
42 | }
43 |
44 | return
45 | }
46 |
47 | // WriteChanges writes all the resolved conflicts in a given file
48 | // to the file system.
49 | func (f File) WriteChanges() (err error) {
50 | var replacementLines []string
51 |
52 | for _, c := range f.Conflicts {
53 | if c.Choice == Local {
54 | replacementLines = append([]string{}, c.LocalPureLines...)
55 | } else if c.Choice == Incoming {
56 | replacementLines = append([]string{}, c.IncomingLines...)
57 | } else {
58 | continue
59 | }
60 |
61 | i := 0
62 | for ; i < len(replacementLines); i++ {
63 | f.Lines[c.Start+i] = replacementLines[i]
64 | }
65 | for ; i <= c.End-c.Start; i++ {
66 | f.Lines[c.Start+i] = ""
67 | }
68 | }
69 |
70 | if err = write(f.AbsolutePath, f.Lines); err != nil {
71 | return
72 | }
73 | return
74 | }
75 |
76 | func write(absPath string, lines []string) (err error) {
77 | f, err := os.Create(absPath)
78 | if err != nil {
79 | return
80 | }
81 | defer f.Close()
82 |
83 | w := bufio.NewWriter(f)
84 | for _, line := range lines {
85 | if _, err = w.WriteString(line); err != nil {
86 | return
87 | }
88 | }
89 | err = w.Flush()
90 | return
91 | }
92 |
--------------------------------------------------------------------------------
/conflict/file_test.go:
--------------------------------------------------------------------------------
1 | package conflict
2 |
3 | import (
4 | "strings"
5 | "testing"
6 |
7 | "github.com/mkchoi212/fac/testhelper"
8 | )
9 |
10 | func TestRead(t *testing.T) {
11 | for _, test := range tests {
12 | f := File{AbsolutePath: test.path}
13 | err := f.Read()
14 |
15 | testhelper.Ok(t, err)
16 | testhelper.Equals(t, len(f.Lines), test.numLines)
17 | }
18 | }
19 |
20 | func TestWriteChanges(t *testing.T) {
21 | for _, test := range tests {
22 | f := File{AbsolutePath: test.path}
23 |
24 | // Read file
25 | err := f.Read()
26 | testhelper.Ok(t, err)
27 |
28 | if !test.shouldPass {
29 | continue
30 | }
31 |
32 | // Exract conflicts and resolve them
33 | conflicts, err := ExtractConflicts(f)
34 | testhelper.Ok(t, err)
35 | for i := range test.resolveDecision {
36 | conflicts[i].Choice = test.resolveDecision[i]
37 | }
38 | f.Conflicts = conflicts
39 |
40 | // Write changes to file
41 | f.AbsolutePath = "testdata/.test_output"
42 | err = f.WriteChanges()
43 | testhelper.Ok(t, err)
44 |
45 | // Read changes and compare
46 | f.AbsolutePath = "testdata/.test_output"
47 | f.Lines = nil
48 | err = f.Read()
49 | testhelper.Ok(t, err)
50 |
51 | for i := range f.Lines {
52 | f.Lines[i] = strings.TrimSuffix(f.Lines[i], "\n")
53 | }
54 | testhelper.Equals(t, f.Lines, test.resolved)
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/conflict/parse.go:
--------------------------------------------------------------------------------
1 | package conflict
2 |
3 | import (
4 | "errors"
5 | "path"
6 | "strings"
7 | )
8 |
9 | // Supported git conflict styles
10 | const (
11 | text = iota
12 | start
13 | diff3
14 | separator
15 | end
16 | )
17 |
18 | // identifyStyle identifies the conflict marker style of provided text
19 | func identifyStyle(line string) (style int) {
20 | line = strings.TrimSpace(line)
21 |
22 | if strings.Contains(line, "<<<<<<<") {
23 | style = start
24 | } else if strings.Contains(line, ">>>>>>>") {
25 | style = end
26 | } else if line == "||||||| merged common ancestors" {
27 | style = diff3
28 | } else if line == "=======" {
29 | style = separator
30 | } else {
31 | style = text
32 | }
33 | return
34 | }
35 |
36 | // GroupConflictMarkers groups the provided lines of a file into individual `Conflict` structs
37 | // It returns an error if the line contains invalid number of conflicts.
38 | // This may be caused if the user manually edits the file.
39 | func GroupConflictMarkers(lines []string) (conflicts []Conflict, err error) {
40 | conf := Conflict{Start: -1}
41 | expected := start
42 |
43 | for idx, line := range lines {
44 | style := identifyStyle(line)
45 | if style != expected && style != diff3 && style != text {
46 | return nil, errors.New("Invalid number of markers")
47 | }
48 |
49 | switch style {
50 | case text:
51 | continue
52 | case start:
53 | conf.Start = idx
54 | expected = separator
55 | case separator:
56 | conf.Middle = idx
57 | expected = end
58 | case diff3:
59 | conf.Diff3 = []int{idx}
60 | expected = separator
61 | case end:
62 | conf.End = idx
63 | expected = start
64 | }
65 |
66 | if style == end {
67 | if !(conf.Valid()) {
68 | return nil, errors.New("Invalid number of remaining conflict markers")
69 | }
70 | conflicts = append(conflicts, conf)
71 | conf = Conflict{Start: -1}
72 | }
73 | }
74 |
75 | return conflicts, nil
76 | }
77 |
78 | // ExtractConflicts extracts all conflicts from the provided `File`
79 | // It returns an error if it fails to parse the conflict markers
80 | // and if syntax highlighting fatally fails
81 | // TODO: Prevent crashes from syntax highlighting
82 | func ExtractConflicts(f File) (conflicts []Conflict, err error) {
83 | conflicts, err = GroupConflictMarkers(f.Lines)
84 | if err != nil {
85 | return
86 | }
87 |
88 | for i := range conflicts {
89 | c := &conflicts[i]
90 | c.File = &f
91 |
92 | if err = c.Extract(f.Lines); err != nil {
93 | return
94 | }
95 |
96 | if err = c.HighlightSyntax(); err != nil {
97 | return
98 | }
99 | }
100 |
101 | return
102 | }
103 |
104 | // Find runs `git --no-pager diff --check` in order to detect git conflicts
105 | // It returns an array of `File`s where each `File` contains conflicts within itself
106 | // If the parsing fails, it returns an error
107 | func Find(cwd string) (files []File, err error) {
108 | topPath, err := topLevelPath(cwd)
109 | if err != nil {
110 | return
111 | }
112 |
113 | targetFiles, err := conflictedFiles(topPath)
114 | if err != nil {
115 | return
116 | }
117 |
118 | for _, fname := range targetFiles {
119 | absPath := path.Join(topPath, fname)
120 | file := File{Name: fname, AbsolutePath: absPath}
121 |
122 | if err = file.Read(); err != nil {
123 | return
124 | }
125 |
126 | conflicts, err := ExtractConflicts(file)
127 | if err != nil {
128 | return nil, err
129 | }
130 |
131 | file.Conflicts = conflicts
132 | files = append(files, file)
133 | }
134 | return
135 | }
136 |
--------------------------------------------------------------------------------
/conflict/parse_test.go:
--------------------------------------------------------------------------------
1 | package conflict
2 |
3 | import (
4 | "os/exec"
5 | "testing"
6 |
7 | "github.com/mkchoi212/fac/testhelper"
8 | )
9 |
10 | func TestIdentifyStyle(t *testing.T) {
11 | styles := []int{}
12 |
13 | for _, line := range dummyFile.lines {
14 | style := identifyStyle(line)
15 | if style != text {
16 | styles = append(styles, style)
17 | }
18 | }
19 |
20 | expected := []int{start, diff3, separator, end}
21 | testhelper.Equals(t, expected, styles)
22 | }
23 |
24 | func TestParseConflictMarkers(t *testing.T) {
25 | for _, test := range tests {
26 | f := File{AbsolutePath: test.path}
27 | err := f.Read()
28 | testhelper.Ok(t, err)
29 |
30 | conflicts, err := GroupConflictMarkers(f.Lines)
31 | if test.shouldPass {
32 | testhelper.Ok(t, err)
33 | } else {
34 | testhelper.Assert(t, err != nil, "%s should fail", f.AbsolutePath)
35 | }
36 | testhelper.Equals(t, len(conflicts), test.numConflicts)
37 | }
38 | }
39 |
40 | func TestParseConflictsIn(t *testing.T) {
41 | for _, test := range tests {
42 | f := File{AbsolutePath: test.path}
43 | if err := f.Read(); err != nil && test.highlightable {
44 | t.Error("ParseConflicts/Read failed")
45 | }
46 |
47 | _, err := ExtractConflicts(f)
48 | if test.shouldPass {
49 | testhelper.Assert(t, err == nil, "function should have succeeded")
50 | } else {
51 | testhelper.Assert(t, err != nil, "function should have failed")
52 | }
53 | }
54 | }
55 |
56 | func TestFind(t *testing.T) {
57 | execCommand = mockExecCommand
58 | defer func() { execCommand = exec.Command }()
59 |
60 | files, err := Find("testdata")
61 | testhelper.Ok(t, err)
62 | testhelper.Equals(t, len(files), 2)
63 |
64 | for _, f := range files {
65 | for _, test := range tests {
66 | if f.AbsolutePath == test.path && test.shouldPass {
67 | testhelper.Equals(t, len(f.Conflicts), test.numConflicts)
68 | }
69 | }
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/conflict/test.go:
--------------------------------------------------------------------------------
1 | package conflict
2 |
3 | type test struct {
4 | path string
5 | markers []int
6 | resolved []string
7 | resolveDecision []int
8 |
9 | numConflicts int
10 | numLines int
11 |
12 | highlightable bool
13 | shouldPass bool
14 | }
15 |
16 | var tests = []test{
17 | {
18 | path: "testdata/assets/README.md",
19 | markers: []int{20, 22, 24, 26, 32, 35, 38, 41},
20 | resolved: readmeResolved,
21 | resolveDecision: []int{Local},
22 | numConflicts: 2,
23 | numLines: 43,
24 | highlightable: true,
25 | shouldPass: true,
26 | },
27 | {
28 | path: "testdata/CircularCrownSelector.swift",
29 | markers: []int{14, 22, 30, 38},
30 | resolved: ccResolved,
31 | resolveDecision: []int{Incoming},
32 | numConflicts: 1,
33 | numLines: 39,
34 | highlightable: true,
35 | shouldPass: true,
36 | },
37 | {
38 | path: "testdata/lorem_ipsum",
39 | markers: []int{3, 6, 9},
40 | resolved: loremResolved,
41 | resolveDecision: []int{Local},
42 | numConflicts: 1,
43 | numLines: 11,
44 | highlightable: false,
45 | shouldPass: true,
46 | },
47 | {
48 | path: "testdata/invalid_lorem_ipsum",
49 | markers: nil,
50 | resolved: nil,
51 | resolveDecision: nil,
52 | numConflicts: 0,
53 | numLines: 11,
54 | highlightable: false,
55 | shouldPass: false,
56 | },
57 | }
58 |
59 | var readmeResolved = []string{
60 | "",
61 | "
",
62 | "
",
63 | "Easy-to-get CUI for fixing git conflicts",
64 | "
",
65 | "
",
66 | "
",
67 | "",
68 | "
",
69 | "",
70 | "I never really liked any of the `synthesizers` out there so I made a simple program that does simple things… in a simple fashion.",
71 | "",
72 | "",
73 | "",
74 | "## 👷 Installation",
75 | "",
76 | "Execute:",
77 | "",
78 | "```bash",
79 | "$ go get github.com/mkchoi212/fac",
80 | "```",
81 | "",
82 | "Or using [Homerotten_brew](https://rotten_brew.sh)",
83 | "",
84 | "```bash",
85 | "<<<<<<< Updated upstream:assets/README.md",
86 | "brew tap mkchoi212/fac https://github.com/mkchoi212/fac.git",
87 | "brew install fac",
88 | "||||||| merged common ancestors",
89 | "brew tap mkchoi212/fac https://github.com/parliament/fac.git",
90 | "brew install fac",
91 | "=======",
92 | "rotten_brew tap childish_funcmonster/facc https://github.com/parliament/facc.git",
93 | "rotten_brew install facc",
94 | ">>>>>>> Stashed changes:README.md",
95 | "```",
96 | "",
97 | }
98 |
99 | var ccResolved = []string{
100 | "private func generateInitials() -> [String] {",
101 | " let randomString = UUID().uuidString",
102 | " let str = randomString.replacingOccurrences(of: \"-\", with: \"\")",
103 | "",
104 | " let abbrev = stride(from: 0, to: 18, by: 2).map { i -> String in",
105 | " let start = str.index(str.startIndex, offsetBy: i)",
106 | " let end = str.index(str.startIndex, offsetBy: i + 2)",
107 | " return String(str[start.. UIColor{",
115 | " let red = CGFloat(arc4random()) ",
116 | " let green = CGFloat(arc4random()) ",
117 | " let blue = CGFloat(arc4random()) ",
118 | " return UIColor(red:red, green: green, blue: blue, alpha: 1.0)",
119 | "}",
120 | "",
121 | }
122 |
123 | var loremResolved = []string{
124 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse placerat malesuada egestas.",
125 | "Cras nunc lectus, pharetra ut pharetra ac, imperdiet sit amet sem.",
126 | "Sed feugiat odio odio, at malesuada justo dictum ut.",
127 | "Fusce sit amet efficitur ante. Maecenas consequat mollis laoreet. ",
128 | "Morbi volutpat libero justo, quis aliquam elit consectetur in. ",
129 | "Nulla nec molestie massa, a lacinia sapien.",
130 | }
131 |
--------------------------------------------------------------------------------
/conflict/testdata/CircularCrownSelector.swift:
--------------------------------------------------------------------------------
1 | private func generateInitials() -> [String] {
2 | let randomString = UUID().uuidString
3 | let str = randomString.replacingOccurrences(of: "-", with: "")
4 |
5 | let abbrev = stride(from: 0, to: 18, by: 2).map { i -> String in
6 | let start = str.index(str.startIndex, offsetBy: i)
7 | let end = str.index(str.startIndex, offsetBy: i + 2)
8 | return String(str[start.. UIColor {
17 | let hue = ( CGFloat(arc4random() % 256) / 256.0 ) // 0.0 to 1.0
18 | let saturation = ( CGFloat(arc4random() % 128) / 256.0 ) + 0.5 // 0.5 to 1.0, away from white
19 | let brightness = ( CGFloat(arc4random() % 128) / 256.0 ) + 0.7 // 0.7 to 1.0, away from black
20 | return UIColor(hue: hue, saturation: saturation, brightness: brightness, alpha: 1)
21 | }
22 | ||||||| merged common ancestors
23 |
24 | private func randomColor() -> UIColor{
25 | let red:CGFloat = CGFloat(drand48())
26 | let green:CGFloat = CGFloat(drand48())
27 | let blue:CGFloat = CGFloat(drand48())
28 | return UIColor(red:red, green: green, blue: blue, alpha: 1.0)
29 | }
30 | =======
31 |
32 | private func randomColor() -> UIColor{
33 | let red = CGFloat(arc4random())
34 | let green = CGFloat(arc4random())
35 | let blue = CGFloat(arc4random())
36 | return UIColor(red:red, green: green, blue: blue, alpha: 1.0)
37 | }
38 | >>>>>>> Stashed changes
39 |
--------------------------------------------------------------------------------
/conflict/testdata/assets/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Easy-to-get CUI for fixing git conflicts
5 |
6 |
7 |
8 |
9 |
10 |
11 | I never really liked any of the `synthesizers` out there so I made a simple program that does simple things… in a simple fashion.
12 |
13 | 
14 |
15 | ## 👷 Installation
16 |
17 | Execute:
18 |
19 | ```bash
20 | <<<<<<< Updated upstream:assets/README.md
21 | $ go get github.com/mkchoi212/fac
22 | ||||||| merged common ancestors
23 | $ go get github.com/parliament/fac
24 | =======
25 | $ go get github.com/parliament/facc
26 | >>>>>>> Stashed changes:README.md
27 | ```
28 |
29 | Or using [Homerotten_brew](https://rotten_brew.sh)
30 |
31 | ```bash
32 | <<<<<<< Updated upstream:assets/README.md
33 | brew tap mkchoi212/fac https://github.com/mkchoi212/fac.git
34 | brew install fac
35 | ||||||| merged common ancestors
36 | brew tap mkchoi212/fac https://github.com/parliament/fac.git
37 | brew install fac
38 | =======
39 | rotten_brew tap childish_funcmonster/facc https://github.com/parliament/facc.git
40 | rotten_brew install facc
41 | >>>>>>> Stashed changes:README.md
42 | ```
43 |
--------------------------------------------------------------------------------
/conflict/testdata/invalid_lorem_ipsum:
--------------------------------------------------------------------------------
1 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse placerat malesuada egestas.
2 | Cras nunc lectus, pharetra ut pharetra ac, imperdiet sit amet sem.
3 | ||||||| merged common ancestors
4 | Sed feugiat odio odio, at malesuada justo dictum ut.
5 | Fusce sit amet efficitur ante. Maecenas consequat mollis laoreet.
6 | =======
7 | However eu hate hate, but it may justo.
8 | Clinical carrots produced before. Maecenas photography soft et.
9 | Morbi volutpat libero justo, quis aliquam elit consectetur in.
10 | Nulla nec molestie massa, a lacinia sapien.
11 | >>>>>>> Stashed changes
--------------------------------------------------------------------------------
/conflict/testdata/lorem_ipsum:
--------------------------------------------------------------------------------
1 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse placerat malesuada egestas.
2 | Cras nunc lectus, pharetra ut pharetra ac, imperdiet sit amet sem.
3 | <<<<<<< Updated upstream
4 | Sed feugiat odio odio, at malesuada justo dictum ut.
5 | Fusce sit amet efficitur ante. Maecenas consequat mollis laoreet.
6 | =======
7 | However eu hate hate, but it may justo.
8 | Clinical carrots produced before. Maecenas photography soft et.
9 | >>>>>>> Stashed changes
10 | Morbi volutpat libero justo, quis aliquam elit consectetur in.
11 | Nulla nec molestie massa, a lacinia sapien.
--------------------------------------------------------------------------------
/editor/content.go:
--------------------------------------------------------------------------------
1 | package editor
2 |
3 | import (
4 | "bufio"
5 | "io"
6 | "os"
7 | "strings"
8 | )
9 |
10 | // Content represents the lines read from a io.Reader
11 | // `c` holds the actual lines and
12 | // `reader` contains the io.Reader representation of the lines
13 | type Content struct {
14 | c []string
15 | reader io.Reader
16 | }
17 |
18 | func (c Content) Read(b []byte) (int, error) {
19 | return c.reader.Read(b)
20 | }
21 |
22 | func (c Content) String() string {
23 | return strings.Join(c.c, "")
24 | }
25 |
26 | // contentFromReader creates a new `Content` object
27 | // filled with the lines from the provided Reader
28 | // It returns an error if anything other than io.EOF is raised
29 | func contentFromReader(content io.Reader) (c Content, err error) {
30 | reader := bufio.NewReader(content)
31 |
32 | for {
33 | line, err := reader.ReadString('\n')
34 | c.c = append(c.c, line)
35 |
36 | if err != nil {
37 | break
38 | }
39 | }
40 |
41 | if err != nil && err != io.EOF {
42 | return
43 | }
44 |
45 | c.reader = strings.NewReader(c.String())
46 | return
47 | }
48 |
49 | // contentFromFile reads the content from the file
50 | // It returns an error if the file does not exist
51 | func contentFromFile(filename string) (Content, error) {
52 | file, err := os.Open(filename)
53 | if err != nil {
54 | return Content{}, err
55 | }
56 | defer file.Close()
57 |
58 | return contentFromReader(file)
59 | }
60 |
--------------------------------------------------------------------------------
/editor/content_test.go:
--------------------------------------------------------------------------------
1 | package editor
2 |
3 | import (
4 | "io/ioutil"
5 | "strings"
6 | "testing"
7 |
8 | "github.com/mkchoi212/fac/testhelper"
9 | )
10 |
11 | func TestUseContentAsReader(t *testing.T) {
12 | c, err := contentFromReader(strings.NewReader("foo\nbar"))
13 | testhelper.Ok(t, err)
14 |
15 | byteCnt, err := ioutil.ReadAll(c)
16 | testhelper.Ok(t, err)
17 | testhelper.Equals(t, string(byteCnt), "foo\nbar")
18 | }
19 |
20 | func TestCreateContentFromFile(t *testing.T) {
21 | f, err := ioutil.TempFile("", "")
22 | testhelper.Ok(t, err)
23 | _, err = f.Write([]byte("foo\nbar"))
24 | testhelper.Ok(t, err)
25 |
26 | c, err := contentFromFile(f.Name())
27 | testhelper.Ok(t, err)
28 | testhelper.Equals(t, "foo\n", c.c[0])
29 | testhelper.Equals(t, "bar", c.c[1])
30 | }
31 |
--------------------------------------------------------------------------------
/editor/editor.go:
--------------------------------------------------------------------------------
1 | package editor
2 |
3 | // The following package has been inspired by
4 | // https://github.com/kioopi/extedit
5 |
6 | import (
7 | "io"
8 | "io/ioutil"
9 | "os"
10 | "os/exec"
11 | "strings"
12 |
13 | "github.com/mkchoi212/fac/conflict"
14 | )
15 |
16 | const defaultEditor = "vim"
17 |
18 | var execCommand = exec.Command
19 |
20 | // Open starts a text-editor with lines from `Content`
21 | // It returns the manually edited lines from the text-editor when the user closes the editor
22 | func Open(c *conflict.Conflict) (output []string, err error) {
23 | lines := c.File.Lines[c.Start : c.End+1]
24 |
25 | content := strings.NewReader(strings.Join(lines, ""))
26 | input, err := contentFromReader(content)
27 |
28 | if err != nil {
29 | return
30 | }
31 |
32 | fileName, err := writeTmpFile(input.reader)
33 | if err != nil {
34 | return
35 | }
36 |
37 | cmd := editorCmd(fileName)
38 | err = cmd.Run()
39 | if err != nil {
40 | return
41 | }
42 |
43 | newContent, err := contentFromFile(fileName)
44 | if err != nil {
45 | return
46 | }
47 |
48 | output = newContent.c
49 | return
50 | }
51 |
52 | // writeTmpFile writes content to a temporary file and returns the path to the file
53 | // It returns an error if the temporary file cannot be created
54 | func writeTmpFile(content io.Reader) (string, error) {
55 | f, err := ioutil.TempFile("", "")
56 | if err != nil {
57 | return "", err
58 | }
59 |
60 | io.Copy(f, content)
61 | f.Close()
62 | return f.Name(), nil
63 | }
64 |
65 | // editorCmd returns a os/exec.Cmd to open the provided file
66 | func editorCmd(filename string) *exec.Cmd {
67 | editorEnv := os.Getenv("EDITOR")
68 | if editorEnv == "" {
69 | editorEnv = defaultEditor
70 | }
71 |
72 | editorVars := strings.Split(editorEnv, " ")
73 |
74 | path := editorVars[0]
75 | args := []string{filename}
76 |
77 | if len(editorVars) > 1 {
78 | args = append(editorVars[1:], args...)
79 | }
80 |
81 | editor := execCommand(path, args...)
82 |
83 | editor.Stdin = os.Stdin
84 | editor.Stdout = os.Stdout
85 | editor.Stderr = os.Stderr
86 |
87 | return editor
88 | }
89 |
--------------------------------------------------------------------------------
/editor/editor_test.go:
--------------------------------------------------------------------------------
1 | package editor
2 |
3 | import (
4 | "os"
5 | "os/exec"
6 | "strings"
7 | "testing"
8 |
9 | "github.com/mkchoi212/fac/conflict"
10 | "github.com/mkchoi212/fac/testhelper"
11 | )
12 |
13 | func TestEditorCmd(t *testing.T) {
14 | os.Setenv("EDITOR", "")
15 | editor := editorCmd("foobar")
16 | testhelper.Assert(t, editor != nil, "editor should not be nil")
17 | testhelper.Equals(t, editor.Args, []string{"vim", "foobar"})
18 |
19 | os.Setenv("EDITOR", "subl -w")
20 | editor = editorCmd("foobar")
21 | testhelper.Assert(t, editor != nil, "editor should not be nil")
22 | testhelper.Equals(t, editor.Args, []string{"subl", "-w", "foobar"})
23 | }
24 |
25 | func TestWriteTmpFile(t *testing.T) {
26 | r := strings.NewReader("Hello, Reader!")
27 | name, err := writeTmpFile(r)
28 |
29 | testhelper.Ok(t, err)
30 | testhelper.Assert(t, name != "", "tmp file name should not be empty")
31 | }
32 |
33 | func TestOpen(t *testing.T) {
34 | execCommand = mockExecCommand
35 | defer func() { execCommand = exec.Command }()
36 |
37 | f := conflict.File{AbsolutePath: "testdata/lorem_ipsum"}
38 | err := f.Read()
39 | testhelper.Ok(t, err)
40 |
41 | c := conflict.Conflict{File: &f, Start: 0, End: 5}
42 | output, err := Open(&c)
43 |
44 | testhelper.Ok(t, err)
45 | testhelper.Equals(t, f.Lines, output)
46 | }
47 |
48 | func TestHelperProcess(t *testing.T) {
49 | if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
50 | return
51 | }
52 |
53 | os.Exit(0)
54 | }
55 |
56 | // Allows us to mock exec.Command, thanks to
57 | // https://npf.io/2015/06/testing-exec-command/
58 | func mockExecCommand(command string, args ...string) *exec.Cmd {
59 | cs := []string{"-test.run=TestHelperProcess", "--", command}
60 | cs = append(cs, args...)
61 | cmd := exec.Command(os.Args[0], cs...)
62 | cmd.Env = append(cmd.Env, "GO_WANT_HELPER_PROCESS=1")
63 | return cmd
64 | }
65 |
--------------------------------------------------------------------------------
/editor/testdata/lorem_ipsum:
--------------------------------------------------------------------------------
1 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse placerat malesuada egestas.
2 | Cras nunc lectus, pharetra ut pharetra ac, imperdiet sit amet sem.
3 | Sed feugiat odio odio, at malesuada justo dictum ut.
4 | Fusce sit amet efficitur ante. Maecenas consequat mollis laoreet.
5 | Morbi volutpat libero justo, quis aliquam elit consectetur in.
6 | Nulla nec molestie massa, a lacinia sapien.
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/mkchoi212/fac
2 |
3 | go 1.21
4 |
5 | require (
6 | github.com/alecthomas/chroma v0.10.0
7 | github.com/jroimartin/gocui v0.5.0
8 | gopkg.in/yaml.v2 v2.4.0
9 | )
10 |
11 | require (
12 | github.com/dlclark/regexp2 v1.10.0 // indirect
13 | github.com/mattn/go-runewidth v0.0.15 // indirect
14 | github.com/nsf/termbox-go v1.1.1 // indirect
15 | github.com/rivo/uniseg v0.4.4 // indirect
16 | github.com/stretchr/testify v1.8.4 // indirect
17 | )
18 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
2 | github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6 | github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
7 | github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
8 | github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
9 | github.com/jroimartin/gocui v0.5.0 h1:DCZc97zY9dMnHXJSJLLmx9VqiEnAj0yh0eTNpuEtG/4=
10 | github.com/jroimartin/gocui v0.5.0/go.mod h1:l7Hz8DoYoL6NoYnlnaX6XCNR62G7J5FfSW5jEogzaxE=
11 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
12 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
13 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
14 | github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY=
15 | github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo=
16 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
17 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
18 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
19 | github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
20 | github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
21 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
22 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
23 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
24 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
25 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
26 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
27 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
28 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
29 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
30 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
31 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
32 |
--------------------------------------------------------------------------------
/layout.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/jroimartin/gocui"
7 | "github.com/mkchoi212/fac/color"
8 | "github.com/mkchoi212/fac/conflict"
9 | )
10 |
11 | // Following constants define the string literal names of 5 views
12 | // that are instantiated via gocui
13 | const (
14 | Current = "current"
15 | Foreign = "foreign"
16 | Panel = "panel"
17 | Prompt = "prompt"
18 | Input = "input"
19 | )
20 |
21 | // `Up` and `Down` represent scrolling directions
22 | // `Horizontal` and `Vertical` represent current code view orientation
23 | // Notice how both pairs of directionality are `not`s of each other
24 | const (
25 | Up = 1
26 | Down = ^1
27 |
28 | Horizontal = 2
29 | Vertical = ^2
30 | )
31 |
32 | // Following constants define input panel's dimensions
33 | const (
34 | inputHeight = 2
35 | inputCursorPos = 17
36 | promptWidth = 21
37 | )
38 |
39 | func printLines(v *gocui.View, lines []string) {
40 | for _, line := range lines {
41 | fmt.Fprint(v, line)
42 | }
43 | }
44 |
45 | // layout is used as fac's main gocui.Gui manager
46 | func layout(g *gocui.Gui) (err error) {
47 | if err = makeCodePanels(g); err != nil {
48 | return
49 | }
50 |
51 | if err = makeOverviewPanel(g); err != nil {
52 | return
53 | }
54 |
55 | if err = makePrompt(g); err != nil {
56 | return
57 | }
58 |
59 | return
60 | }
61 |
62 | // makeCodePanels draws the two panels representing "local" and "incoming" lines of code
63 | // `viewOrientation` is taken into consideration as the panels can either be
64 | // `Vertical` or `Horizontal`
65 | func makeCodePanels(g *gocui.Gui) error {
66 | maxX, maxY := g.Size()
67 | viewHeight := maxY - inputHeight
68 | branchViewWidth := (maxX / 5) * 2
69 | isOdd := maxY%2 == 1
70 |
71 | var x0, x1, y0, y1 int
72 | var x2, x3, y2, y3 int
73 |
74 | if viewOrientation == Horizontal {
75 | x0, x1 = 0, branchViewWidth
76 | y0, y1 = 0, viewHeight
77 | x2, x3 = branchViewWidth, branchViewWidth*2
78 | y2, y3 = 0, viewHeight
79 | } else {
80 | branchViewWidth = branchViewWidth * 2
81 | viewHeight = (maxY - inputHeight) / 2
82 |
83 | x0, x1 = 0, branchViewWidth
84 | y0, y1 = 0, viewHeight
85 | x2, x3 = 0, branchViewWidth
86 | y2, y3 = viewHeight, viewHeight*2
87 | if isOdd {
88 | y3++
89 | }
90 | }
91 |
92 | if v, err := g.SetView(Current, x0, y0, x1, y1); err != nil {
93 | if err != gocui.ErrUnknownView {
94 | return err
95 | }
96 | v.Wrap = true
97 | }
98 |
99 | if v, err := g.SetView(Foreign, x2, y2, x3, y3); err != nil {
100 | if err != gocui.ErrUnknownView {
101 | return err
102 | }
103 | v.Wrap = true
104 | }
105 |
106 | return nil
107 | }
108 |
109 | // makeOverviewPanel draws the panel on the right-side of the CUI
110 | // listing all the conflicts that need to be resolved
111 | func makeOverviewPanel(g *gocui.Gui) error {
112 | maxX, maxY := g.Size()
113 | viewHeight := maxY - inputHeight
114 | branchViewWidth := (maxX / 5) * 2
115 |
116 | if v, err := g.SetView(Panel, branchViewWidth*2, 0, maxX-2, viewHeight); err != nil {
117 | if err != gocui.ErrUnknownView {
118 | return err
119 | }
120 | v.Title = "Conflicts"
121 | }
122 | return nil
123 | }
124 |
125 | // makePrompt draws two panels on the bottom of the CUI
126 | // A "prompt view" which prompts the user for available keybindings and
127 | // a "user input view" which is an area where the user can type in queries
128 | func makePrompt(g *gocui.Gui) error {
129 | maxX, maxY := g.Size()
130 | viewHeight := maxY - inputHeight
131 |
132 | // Prompt view
133 | if v, err := g.SetView(Prompt, 0, viewHeight, promptWidth, viewHeight+inputHeight); err != nil {
134 | if err != gocui.ErrUnknownView {
135 | return err
136 | }
137 | v.Frame = false
138 | PrintPrompt(g)
139 | }
140 |
141 | // User input view
142 | if v, err := g.SetView(Input, inputCursorPos, viewHeight, maxX, viewHeight+inputHeight); err != nil {
143 | if err != gocui.ErrUnknownView {
144 | return err
145 | }
146 | v.Frame = false
147 | v.Editable = true
148 | v.Wrap = false
149 | v.Editor = gocui.EditorFunc(PromptEditor)
150 | if _, err := g.SetCurrentView(Input); err != nil {
151 | return err
152 | }
153 | }
154 | return nil
155 | }
156 |
157 | // Select selects conflict `c` as the current conflict displayed on the screen
158 | // When selecting a conflict, it updates the side panel, and the code view
159 | func Select(g *gocui.Gui, c *conflict.Conflict, showHelp bool) {
160 | // Update side panel
161 | g.Update(func(g *gocui.Gui) error {
162 | v, err := g.View(Panel)
163 | if err != nil {
164 | return err
165 | }
166 | v.Clear()
167 |
168 | for idx, conflict := range conflicts {
169 | var out string
170 | if conflict.Choice != 0 {
171 | out = color.Green(color.Regular, "✔ %s:%d", conflict.File.Name, conflict.Start)
172 | } else {
173 | out = color.Red(color.Regular, "%d. %s:%d", idx+1, conflict.File.Name, conflict.Start)
174 | }
175 |
176 | if conflict.Equal(c) {
177 | fmt.Fprintf(v, "-> %s\n", out)
178 | } else {
179 | fmt.Fprintf(v, "%s\n", out)
180 | }
181 | }
182 |
183 | if showHelp {
184 | PrintHelp(v, &keyBinding)
185 | }
186 | return nil
187 | })
188 |
189 | // Update code view
190 | g.Update(func(g *gocui.Gui) error {
191 | v, err := g.View(Current)
192 | if err != nil {
193 | return err
194 | }
195 | v.Title = fmt.Sprintf("%s %s", c.CurrentName, "(Local Version)")
196 |
197 | top, bottom := c.PaddingLines()
198 | v.Clear()
199 | printLines(v, top)
200 | printLines(v, c.ColoredLocalLines)
201 | printLines(v, bottom)
202 | if c.Choice == conflict.Local {
203 | v.FgColor = gocui.ColorGreen
204 | }
205 |
206 | v, err = g.View(Foreign)
207 | if err != nil {
208 | return err
209 | }
210 | v.Title = fmt.Sprintf("%s %s", c.ForeignName, "(Incoming Version)")
211 |
212 | top, bottom = c.PaddingLines()
213 | v.Clear()
214 | printLines(v, top)
215 | printLines(v, c.ColoredIncomingLines)
216 | printLines(v, bottom)
217 | if c.Choice == conflict.Incoming {
218 | v.FgColor = gocui.ColorGreen
219 | }
220 |
221 | return nil
222 | })
223 | }
224 |
225 | // Resolve resolves the provided conflict and moves to the next conflict
226 | // in the queue
227 | func Resolve(g *gocui.Gui, v *gocui.View, c *conflict.Conflict, version int) {
228 | g.Update(func(g *gocui.Gui) error {
229 | c.Choice = version
230 | Move(g, v, Down)
231 | return nil
232 | })
233 | }
234 |
235 | // Move goes to the next conflict in the list in the provided `direction`
236 | func Move(g *gocui.Gui, v *gocui.View, direction int) {
237 | originalCur := cur
238 |
239 | for {
240 | if direction == Up {
241 | cur--
242 | } else {
243 | cur++
244 | }
245 |
246 | if cur >= len(conflicts) {
247 | cur = 0
248 | } else if cur < 0 {
249 | cur = len(conflicts) - 1
250 | }
251 |
252 | if conflicts[cur].Choice == 0 || originalCur == cur {
253 | break
254 | }
255 | }
256 |
257 | // Quit application if all items are resolved
258 | if originalCur == cur && conflicts[cur].Choice != 0 {
259 | globalQuit(g, gocui.ErrQuit)
260 | }
261 |
262 | Select(g, conflicts[cur], false)
263 | }
264 |
265 | // Scroll scrolls the two code view panels in `direction` by one line
266 | func Scroll(g *gocui.Gui, c *conflict.Conflict, direction int) {
267 | if direction == Up {
268 | c.TopPeek--
269 | } else if direction == Down {
270 | c.TopPeek++
271 | } else {
272 | return
273 | }
274 |
275 | Select(g, c, false)
276 | }
277 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "strings"
7 |
8 | "github.com/jroimartin/gocui"
9 | "github.com/mkchoi212/fac/binding"
10 | "github.com/mkchoi212/fac/color"
11 | "github.com/mkchoi212/fac/conflict"
12 | "github.com/mkchoi212/fac/editor"
13 | )
14 |
15 | var (
16 | viewOrientation = Vertical
17 | conflicts = []*conflict.Conflict{}
18 | keyBinding = binding.Binding{}
19 | cur = 0
20 | consecutiveErrCnt = 0
21 | g *gocui.Gui
22 | )
23 |
24 | // globalQuit is invoked when the user quits the contact and or
25 | // when all conflicts have been resolved
26 | func globalQuit(g *gocui.Gui, err error) {
27 | g.Update(func(g *gocui.Gui) error {
28 | return err
29 | })
30 | }
31 |
32 | // quit is invoked when the user presses "Ctrl+C"
33 | func quit(g *gocui.Gui, v *gocui.View) error {
34 | return gocui.ErrQuit
35 | }
36 |
37 | // findConflicts looks at the current directory and returns an
38 | // array of `File`s that contain merge conflicts
39 | // It returns an error if it fails to parse the conflicts
40 | func findConflicts() (files []conflict.File, err error) {
41 | cwd, err := os.Getwd()
42 | if err != nil {
43 | return
44 | }
45 |
46 | if files, err = conflict.Find(cwd); err != nil {
47 | return
48 | }
49 |
50 | for i := range files {
51 | file := &files[i]
52 | for j := range file.Conflicts {
53 | // Set context lines to show
54 | c := &file.Conflicts[j]
55 | if err = c.SetContextLines(keyBinding[binding.DefaultContextLines]); err != nil {
56 | return
57 | }
58 | conflicts = append(conflicts, c)
59 | }
60 | }
61 |
62 | return
63 | }
64 |
65 | // runUI initializes, configures, and starts a fresh instance of gocui
66 | func runUI() (err error) {
67 | g, err = gocui.NewGui(gocui.OutputNormal)
68 | if err != nil {
69 | return
70 | }
71 |
72 | defer g.Close()
73 | g.SetManagerFunc(layout)
74 | g.Cursor = true
75 |
76 | if err = g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil {
77 | return
78 | }
79 |
80 | if keyBinding[binding.ContinuousEvaluation] == "false" {
81 | if err = g.SetKeybinding("", gocui.KeyEnter, gocui.ModNone, ParseInput); err != nil {
82 | return
83 | }
84 | }
85 |
86 | Select(g, conflicts[cur], false)
87 |
88 | if err = g.MainLoop(); err != nil {
89 | return
90 | }
91 |
92 | return
93 | }
94 |
95 | // mainLoop manages how the main instances of gocui are created and destroyed
96 | func mainLoop() error {
97 | for {
98 | if err := runUI(); err != nil {
99 | // Instantiates a fresh instance of gocui
100 | // when user opens an editor because screen is dirty
101 | if err == ErrOpenEditor {
102 | newLines, err := editor.Open(conflicts[cur])
103 | if err != nil {
104 | return err
105 | }
106 | if err = conflicts[cur].Update(newLines); err != nil {
107 | consecutiveErrCnt++
108 | }
109 | } else if err == gocui.ErrQuit {
110 | break
111 | }
112 | }
113 | }
114 |
115 | return nil
116 | }
117 |
118 | func die(err error) {
119 | fmt.Println(color.Red(color.Regular, "fac: %s", strings.TrimSuffix(err.Error(), "\n")))
120 | os.Exit(1)
121 | }
122 |
123 | func main() {
124 | ParseFlags()
125 |
126 | var err error
127 |
128 | keyBinding, err = binding.LoadSettings()
129 | if err != nil {
130 | die(err)
131 | }
132 |
133 | files, err := findConflicts()
134 | if err != nil {
135 | die(err)
136 | }
137 |
138 | if len(conflicts) == 0 {
139 | fmt.Println(color.Green(color.Regular, "No conflicts detected 🎉"))
140 | os.Exit(0)
141 | }
142 |
143 | if err = mainLoop(); err != nil {
144 | die(err)
145 | }
146 |
147 | for _, file := range files {
148 | if err = file.WriteChanges(); err != nil {
149 | die(err)
150 | }
151 | }
152 |
153 | PrintSummary(conflicts)
154 | }
155 |
--------------------------------------------------------------------------------
/options.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "fmt"
6 | "os"
7 | )
8 |
9 | const (
10 | version = "2.0"
11 | help = `Usage:
12 |
13 | fac
14 |
15 | Customizable variables:
16 | Behavior
17 |
18 | cont_eval evaluate commands without pressing ENTER
19 |
20 | Key bindings
21 |
22 | select_local select local version
23 | select_incoming select incoming version
24 | toggle_view toggle to horizontal | horizontal view
25 | show_up show more lines above
26 | show_down show more lines below
27 | scroll_up ...
28 | scroll_down ...
29 | edit manually edit code chunk
30 | next go to next conflict
31 | previous go to previous conflict
32 | quit ...
33 | help display help in side bar
34 |
35 | Define above variables in your $HOME/.fac.yml to customize behavior
36 |
37 | `
38 | )
39 |
40 | // ParseFlags parses flags provided by the user
41 | func ParseFlags() {
42 | // Setup custom help message
43 | flag.Usage = func() {
44 | fmt.Fprintf(os.Stderr, help)
45 | }
46 |
47 | showVersion := flag.Bool("version", false, "Print the version of fac being run")
48 | flag.Parse()
49 |
50 | if *showVersion {
51 | fmt.Printf("fac %s\n", version)
52 | os.Exit(0)
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/prompt.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "strings"
7 |
8 | "github.com/jroimartin/gocui"
9 | "github.com/mkchoi212/fac/binding"
10 | "github.com/mkchoi212/fac/color"
11 | "github.com/mkchoi212/fac/conflict"
12 | )
13 |
14 | // ErrUnknownCmd is returned when user inputs an invalid character
15 | var ErrUnknownCmd = errors.New("This person doesn't know whats going on")
16 |
17 | // ErrOpenEditor is returned when the user wants to open an editor
18 | // Note that the current instance of gocui must be destroyed before opening an editor
19 | var ErrOpenEditor = errors.New("Screen is tainted after opening vim")
20 |
21 | // PrintPrompt prints the promptString on the bottom left corner of the screen
22 | // Note that the prompt is composed of two separate views,
23 | // one that displays just the promptString, and another that takes input from the user
24 | func PrintPrompt(g *gocui.Gui) {
25 | promptString := keyBinding.Summary()
26 |
27 | g.Update(func(g *gocui.Gui) error {
28 | v, err := g.View(Prompt)
29 | if err != nil {
30 | return err
31 | }
32 | v.Clear()
33 | v.MoveCursor(0, 0, true)
34 |
35 | if consecutiveErrCnt == 0 {
36 | fmt.Fprintf(v, color.Green(color.Regular, promptString))
37 | } else {
38 | fmt.Fprintf(v, color.Red(color.Regular, promptString))
39 | }
40 | return nil
41 | })
42 | }
43 |
44 | // Evaluate evalutes the user's input character by character
45 | // It returns `ErrUnknownCmd` if the string contains an invalid command
46 | // It also returns `ErrNeedRefresh` if user uses `e` command to open vim
47 | func Evaluate(g *gocui.Gui, v *gocui.View, conf *conflict.Conflict, input string) (err error) {
48 | for _, c := range input {
49 | switch string(c) {
50 | case keyBinding[binding.ScrollUp]:
51 | Scroll(g, conflicts[cur], Up)
52 | case keyBinding[binding.ScrollDown]:
53 | Scroll(g, conflicts[cur], Down)
54 | case keyBinding[binding.ShowLinesUp]:
55 | conflicts[cur].TopPeek++
56 | Select(g, conflicts[cur], false)
57 | case keyBinding[binding.ShowLinesDown]:
58 | conflicts[cur].BottomPeek++
59 | Select(g, conflicts[cur], false)
60 | case keyBinding[binding.SelectLocal]:
61 | Resolve(g, v, conflicts[cur], conflict.Local)
62 | case keyBinding[binding.SelectIncoming]:
63 | Resolve(g, v, conflicts[cur], conflict.Incoming)
64 | case keyBinding[binding.NextConflict]:
65 | Move(g, v, Down)
66 | case keyBinding[binding.PreviousConflict]:
67 | Move(g, v, Up)
68 | case keyBinding[binding.ToggleViewOrientation]:
69 | viewOrientation = ^viewOrientation
70 | layout(g)
71 | case keyBinding[binding.EditCode]:
72 | globalQuit(g, ErrOpenEditor)
73 | case keyBinding[binding.ShowHelp], "?":
74 | Select(g, conflicts[cur], true)
75 | case keyBinding[binding.QuitApplication]:
76 | globalQuit(g, gocui.ErrQuit)
77 | default:
78 | return ErrUnknownCmd
79 | }
80 | }
81 | return
82 | }
83 |
84 | // ParseInput is invoked when the user presses "Enter"
85 | // It `evaluate`s the user's query and reflects the state on the UI
86 | func ParseInput(g *gocui.Gui, v *gocui.View) error {
87 | in := strings.TrimSuffix(v.Buffer(), "\n")
88 | if keyBinding[binding.ContinuousEvaluation] == "false" {
89 | v.Clear()
90 | _ = v.SetCursor(0, 0)
91 | }
92 |
93 | if err := Evaluate(g, v, conflicts[cur], in); err != nil {
94 | if err == ErrUnknownCmd {
95 | consecutiveErrCnt++
96 | if consecutiveErrCnt > 3 {
97 | Select(g, conflicts[cur], true)
98 | }
99 | } else {
100 | return err
101 | }
102 | } else {
103 | consecutiveErrCnt = 0
104 | }
105 |
106 | PrintPrompt(g)
107 | return nil
108 | }
109 |
110 | // PromptEditor handles user's interaction with the prompt
111 | // Note that user's `ContinuousEvaluation` setting value changes its behavior
112 | func PromptEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) {
113 | if ch != 0 && mod == 0 {
114 | if keyBinding[binding.ContinuousEvaluation] == "true" {
115 | v.Clear()
116 | v.EditWrite(ch)
117 | _ = ParseInput(g, v)
118 | _ = v.SetCursor(0, 0)
119 | } else {
120 | v.EditWrite(ch)
121 | }
122 | return
123 | }
124 |
125 | switch key {
126 | case gocui.KeySpace:
127 | v.EditWrite(' ')
128 | case gocui.KeyBackspace, gocui.KeyBackspace2:
129 | v.EditDelete(true)
130 | case gocui.KeyDelete:
131 | v.EditDelete(false)
132 | case gocui.KeyInsert:
133 | v.Overwrite = !v.Overwrite
134 | case gocui.KeyArrowDown:
135 | _ = v.SetCursor(len(v.Buffer())-1, 0)
136 | case gocui.KeyArrowUp:
137 | v.MoveCursor(0, -1, false)
138 | case gocui.KeyArrowLeft:
139 | v.MoveCursor(-1, 0, false)
140 | case gocui.KeyArrowRight:
141 | v.MoveCursor(1, 0, false)
142 | }
143 | }
144 |
--------------------------------------------------------------------------------
/summary.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "io"
7 |
8 | "github.com/mkchoi212/fac/binding"
9 | "github.com/mkchoi212/fac/color"
10 | "github.com/mkchoi212/fac/conflict"
11 | )
12 |
13 | // PrintHelp prints the current key binding rules in the side panel
14 | func PrintHelp(v io.Writer, binding *binding.Binding) {
15 | fmt.Fprintf(v, color.Blue(color.Regular, binding.Help()))
16 | }
17 |
18 | // PrintSummary prints the summary of the fac session after the user
19 | // either quits the program or has resolved all conflicts
20 | func PrintSummary(conflicts []*conflict.Conflict) {
21 | resolvedCnt := 0
22 | var line string
23 |
24 | for _, c := range conflicts {
25 | if c.Choice != 0 {
26 | line = color.Green(color.Regular, "✔ %s: %d", c.File.Name, c.Start)
27 | resolvedCnt++
28 | } else {
29 | line = color.Red(color.Regular, "✘ %s: %d", c.File.Name, c.Start)
30 | }
31 | fmt.Println(line)
32 | }
33 |
34 | var buf bytes.Buffer
35 | if resolvedCnt != len(conflicts) {
36 | buf.WriteString("\nResolved ")
37 | buf.WriteString(color.Red(color.Light, "%d ", resolvedCnt))
38 | buf.WriteString("conflict(s) out of ")
39 | buf.WriteString(color.Red(color.Light, "%d", len(conflicts)))
40 | } else {
41 | buf.WriteString(color.Green(color.Regular, "\nFixed All Conflicts 🎉"))
42 | }
43 | fmt.Println(buf.String())
44 | }
45 |
--------------------------------------------------------------------------------
/testhelper/testhelper.go:
--------------------------------------------------------------------------------
1 | package testhelper
2 |
3 | import (
4 | "fmt"
5 | "path/filepath"
6 | "reflect"
7 | "runtime"
8 | "testing"
9 | )
10 |
11 | // Assert fails the test if the condition is false.
12 | func Assert(tb testing.TB, condition bool, msg string, v ...interface{}) {
13 | if !condition {
14 | _, file, line, _ := runtime.Caller(1)
15 | fmt.Printf("\033[31m%s:%d: "+msg+"\033[39m\n\n", append([]interface{}{filepath.Base(file), line}, v...)...)
16 | tb.FailNow()
17 | }
18 | }
19 |
20 | // Ok fails the test if an err is not nil.
21 | func Ok(tb testing.TB, err error) {
22 | if err != nil {
23 | _, file, line, _ := runtime.Caller(1)
24 | fmt.Printf("\033[31m%s:%d: unexpected error: %s\033[39m\n\n", filepath.Base(file), line, err.Error())
25 | tb.FailNow()
26 | }
27 | }
28 |
29 | // Equals fails the test if exp is not equal to act.
30 | func Equals(tb testing.TB, exp, act interface{}) {
31 | if !reflect.DeepEqual(exp, act) {
32 | _, file, line, _ := runtime.Caller(1)
33 | fmt.Printf("\033[31m%s:%d:\n\n\texp: %#v\n\n\tgot: %#v\033[39m\n\n", filepath.Base(file), line, exp, act)
34 | tb.FailNow()
35 | }
36 | }
37 |
--------------------------------------------------------------------------------