├── .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 | ![Preview](https://i.imgur.com/GsJMRIp.gif) 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"}, 17 | {Red, []string{"%s %s", "foobar", "hey"}, "\033[31;1mfoobar hey"}, 18 | {Green, []string{"%s", ""}, "\033[32;1m"}, 19 | {Blue, []string{"foobar"}, "\033[34;1mfoobar"}, 20 | {Yellow, []string{"foobar"}, "\033[33;1mfoobar"}, 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 | "![](./assets/overview.png)", 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 | ![](./assets/overview.png) 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 | --------------------------------------------------------------------------------