├── screenshots ├── quit_prompt.png ├── diff_exchange.png ├── bmr_mixed_layout.png ├── toggle_layout_1.png ├── toggle_layout_2.png ├── toggle_layout_3.png ├── toggle_layouts.png ├── bmr_3splits_layout.png ├── vimdiff_default_tool.png ├── airline_merge_indicator.png ├── layout_advanced_customization.png └── mergetool_mr_2way_default_layout.png ├── LICENSE ├── plugin └── mergetool.vim ├── autoload └── mergetool.vim └── readme.md /screenshots/quit_prompt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samoshkin/vim-mergetool/HEAD/screenshots/quit_prompt.png -------------------------------------------------------------------------------- /screenshots/diff_exchange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samoshkin/vim-mergetool/HEAD/screenshots/diff_exchange.png -------------------------------------------------------------------------------- /screenshots/bmr_mixed_layout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samoshkin/vim-mergetool/HEAD/screenshots/bmr_mixed_layout.png -------------------------------------------------------------------------------- /screenshots/toggle_layout_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samoshkin/vim-mergetool/HEAD/screenshots/toggle_layout_1.png -------------------------------------------------------------------------------- /screenshots/toggle_layout_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samoshkin/vim-mergetool/HEAD/screenshots/toggle_layout_2.png -------------------------------------------------------------------------------- /screenshots/toggle_layout_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samoshkin/vim-mergetool/HEAD/screenshots/toggle_layout_3.png -------------------------------------------------------------------------------- /screenshots/toggle_layouts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samoshkin/vim-mergetool/HEAD/screenshots/toggle_layouts.png -------------------------------------------------------------------------------- /screenshots/bmr_3splits_layout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samoshkin/vim-mergetool/HEAD/screenshots/bmr_3splits_layout.png -------------------------------------------------------------------------------- /screenshots/vimdiff_default_tool.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samoshkin/vim-mergetool/HEAD/screenshots/vimdiff_default_tool.png -------------------------------------------------------------------------------- /screenshots/airline_merge_indicator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samoshkin/vim-mergetool/HEAD/screenshots/airline_merge_indicator.png -------------------------------------------------------------------------------- /screenshots/layout_advanced_customization.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samoshkin/vim-mergetool/HEAD/screenshots/layout_advanced_customization.png -------------------------------------------------------------------------------- /screenshots/mergetool_mr_2way_default_layout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samoshkin/vim-mergetool/HEAD/screenshots/mergetool_mr_2way_default_layout.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Alexey Samoshkin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /plugin/mergetool.vim: -------------------------------------------------------------------------------- 1 | 2 | " check whether this script is already loaded 3 | if exists("g:loaded_mergetool") 4 | finish 5 | endif 6 | let g:loaded_mergetool = 1 7 | 8 | let g:mergetool_in_merge_mode = 0 9 | 10 | command! -nargs=0 MergetoolStart call mergetool#start() 11 | command! -nargs=0 MergetoolStop call mergetool#stop() 12 | command! -nargs=0 MergetoolToggle call mergetool#toggle() 13 | command! -nargs=1 MergetoolSetLayout call mergetool#set_layout() 14 | command! -nargs=1 MergetoolToggleLayout call mergetool#toggle_layout() 15 | command! -nargs=0 MergetoolPreferLocal call mergetool#prefer_revision('local') 16 | command! -nargs=0 MergetoolPreferRemote call mergetool#prefer_revision('remote') 17 | 18 | nnoremap (MergetoolToggle) :call mergetool#toggle() 19 | 20 | " {{{ Diff exchange 21 | 22 | " Do either diffget or diffput, depending on given direction 23 | " and whether the window has adjacent window in a given direction 24 | " h| + window on right = diffget from right win 25 | " h| + no window on right = diffput to left win 26 | " l| + window on left = diffget from left win 27 | " l| + no window on left = diffput to right win 28 | " Same logic applies for vertical directions: 'j' and 'k' 29 | 30 | let s:directions = { 31 | \ 'h': 'l', 32 | \ 'l': 'h', 33 | \ 'j': 'k', 34 | \ 'k': 'j' } 35 | 36 | function s:DiffExchange(dir) 37 | let oppdir = s:directions[a:dir] 38 | 39 | let winoppdir = s:FindWindowOnDir(oppdir) 40 | if (winoppdir != -1) 41 | execute "diffget " . winbufnr(winoppdir) 42 | else 43 | let windir = s:FindWindowOnDir(a:dir) 44 | if (windir != -1) 45 | execute "diffput " . winbufnr(windir) 46 | else 47 | echohl WarningMsg 48 | echo 'Cannot exchange diff. Found only single window' 49 | echohl None 50 | endif 51 | endif 52 | endfunction 53 | 54 | " Finds window in given direction and returns it win number 55 | " If no window found, returns -1 56 | function s:FindWindowOnDir(dir) 57 | let oldwin = winnr() 58 | 59 | execute "noautocmd wincmd " . a:dir 60 | let curwin = winnr() 61 | if (oldwin != curwin) 62 | noautocmd wincmd p 63 | return curwin 64 | else 65 | return -1 66 | endif 67 | endfunction 68 | 69 | " Commands and mappings for diff exchange commands 70 | command! -nargs=0 MergetoolDiffExchangeLeft call s:DiffExchange('h') 71 | command! -nargs=0 MergetoolDiffExchangeRight call s:DiffExchange('l') 72 | command! -nargs=0 MergetoolDiffExchangeDown call s:DiffExchange('j') 73 | command! -nargs=0 MergetoolDiffExchangeUp call s:DiffExchange('k') 74 | 75 | nnoremap (MergetoolDiffExchangeLeft) :call DiffExchange('h') 76 | nnoremap (MergetoolDiffExchangeRight) :call DiffExchange('l') 77 | nnoremap (MergetoolDiffExchangeDown) :call DiffExchange('j') 78 | nnoremap (MergetoolDiffExchangeUp) :call DiffExchange('k') 79 | 80 | " }}} 81 | -------------------------------------------------------------------------------- /autoload/mergetool.vim: -------------------------------------------------------------------------------- 1 | 2 | function s:noop(...) 3 | endfunction 4 | 5 | " Configuration settings 6 | let g:mergetool_layout = get(g:, 'mergetool_layout', 'mr') 7 | let g:mergetool_prefer_revision = get(g:, 'mergetool_prefer_revision', 'local') 8 | let g:MergetoolSetLayoutCallback = get(g:, 'MergetoolSetLayoutCallback', function('s:noop')) 9 | 10 | " {{{ Public exports 11 | 12 | let g:mergetool_in_merge_mode = 0 13 | 14 | let s:run_as_git_mergetool = 0 15 | let s:current_layout = '' 16 | 17 | function! mergetool#start() "{{{ 18 | " If file does not have conflict markers, it's a wrong target for mergetool 19 | if !s:has_conflict_markers() 20 | echohl WarningMsg 21 | echo "File does not have correct conflict markers" 22 | echohl None 23 | return 24 | endif 25 | 26 | " It's required to use diff3 conflict style, so markers include common base revision 27 | if !s:has_conflict_markers_in_diff3_style() 28 | echohl WarningMsg 29 | echo "Conflict markers miss common base revision. Ensure you're using 'merge.conflictStyle=diff3' in your gitconfig" 30 | echohl None 31 | return 32 | endif 33 | 34 | " Remember original file properties 35 | let s:mergedfile_bufnr = bufnr('%') 36 | let s:mergedfile_name = expand('%') 37 | let s:mergedfile_contents = join(getline(0, "$"), "\n") . "\n" 38 | let s:mergedfile_fileformat = &fileformat 39 | let s:mergedfile_filetype = &filetype 40 | 41 | " Detect if we're run as 'git mergetool' by presence of BASE|LOCAL|REMOTE buf names 42 | let s:run_as_git_mergetool = bufnr('BASE') != -1 && 43 | \ bufnr('LOCAL') != -1 && 44 | \ bufnr('REMOTE') != -1 45 | 46 | " Open in new tab, do not break existing layout 47 | if !s:run_as_git_mergetool 48 | tab split 49 | endif 50 | 51 | let g:mergetool_in_merge_mode = 1 52 | 53 | call mergetool#prefer_revision(g:mergetool_prefer_revision) 54 | call mergetool#set_layout(g:mergetool_layout) 55 | endfunction "}}} 56 | 57 | " Stop mergetool effect depends on: 58 | " - when run as 'git mergetool' 59 | " - when run from Vim directly on file with conflict markers 60 | 61 | " When run as 'git mergetool', to decide merge result Git would check: 62 | " - whether file was changed, if 'mergetool.trustExitCode' == false 63 | " - mergetool program exit code, otherwise 64 | function! mergetool#stop() " {{{ 65 | call s:ensure_in_mergemode() 66 | 67 | while 1 68 | let choice = input('Was the merge successful? (y)es, (n)o, (c)ancel: ') 69 | if choice ==? 'y' || choice ==? 'n' || choice ==? 'c' 70 | break 71 | endif 72 | endwhile 73 | redraw! 74 | 75 | if choice ==? 'c' 76 | return 77 | endif 78 | 79 | " Load buffer with merged file 80 | execute "buffer " . s:mergedfile_bufnr 81 | 82 | if s:run_as_git_mergetool 83 | " When run as 'git mergetool', and merge was unsuccessful 84 | " discard local changes and do not write buffer to disk 85 | " also exit with nonzero code 86 | 87 | if choice ==? 'n' 88 | edit! 89 | cquit 90 | else 91 | write 92 | qall! 93 | endif 94 | 95 | else 96 | " When run directly from Vim, 97 | " just restore merged file buffer to the original version 98 | " and close tab we've opened on start 99 | 100 | if choice ==? 'n' 101 | silent call s:restore_merged_file_contents() 102 | else 103 | write 104 | endif 105 | 106 | let g:mergetool_in_merge_mode = 0 107 | tabclose 108 | endif 109 | endfunction " }}} 110 | 111 | 112 | function! mergetool#toggle() " {{{ 113 | if g:mergetool_in_merge_mode 114 | call mergetool#stop() 115 | else 116 | call mergetool#start() 117 | endif 118 | endfunction " }}} 119 | 120 | " Opens set of windows with merged file and various file revisions 121 | " Supported layout options: 122 | " - w, 'MERGED' revision as passed by Git, or working tree version of merged file 123 | " - r, revision obtained by removing conflict markers and picking up 'theirs' side 124 | " - R, 'REMOTE' revision as passed by Git, or revision for unmerged file obtained from index stage :3: 125 | " - l, revision obtained by removing conflict markers and picking up 'ours' side 126 | " - L, 'LOCAL' revision as passed by Git, or revision for unmerged file obtained from index stage :2: 127 | " - b, revision obtained by removing conflict markers and picking up 'common' side 128 | " - B, 'BASE' revision as passed by Git, or revision for unmerged file obtained from index stage :1: 129 | function! mergetool#set_layout(layout) " {{{ 130 | call s:ensure_in_mergemode() 131 | 132 | if a:layout =~? '[^rlbm,]' 133 | throw "Unknown layout option: " . a:layout 134 | endif 135 | 136 | let splits = [] 137 | 138 | let abbrevs = { 139 | \ 'b': 'base', 140 | \ 'B': 'BASE', 141 | \ 'r': 'remote', 142 | \ 'R': 'REMOTE', 143 | \ 'l': 'local', 144 | \ 'L': 'LOCAL' } 145 | let is_first_split = 1 146 | let split_dir = 'vert rightbelow' 147 | 148 | if s:goto_win_with_merged_file() 149 | let l:_winstate = winsaveview() 150 | endif 151 | 152 | " Before changing layout, turn off diff mode in all visible windows 153 | windo diffoff 154 | 155 | " For each char in layout, open split window and load revision 156 | for labbr in split(a:layout, '\zs') 157 | 158 | " ',' is to make next split horizontal 159 | if labbr ==? ',' 160 | let split_dir='botright' 161 | continue 162 | endif 163 | 164 | " Create next split, and reset split direction to vertical 165 | execute split_dir . " split" 166 | let split_dir = 'vert rightbelow' 167 | 168 | " After first split is created, close all other windows 169 | if is_first_split 170 | wincmd o 171 | let is_first_split = 0 172 | endif 173 | 174 | if labbr ==? 'm' 175 | " For merged file itself, just load its buffer 176 | execute "buffer " . s:mergedfile_bufnr 177 | else 178 | silent call s:load_revision(abbrevs[labbr]) 179 | endif 180 | 181 | call add(splits, { 182 | \ 'layout': a:layout, 183 | \ 'split': labbr, 184 | \ 'filetype': s:mergedfile_filetype, 185 | \ 'bufnr': bufnr(''), 186 | \ 'winnr': winnr() }) 187 | endfor 188 | 189 | let s:current_layout = a:layout 190 | windo diffthis 191 | 192 | " Iterate over created splits and fire callback 193 | for l:split in splits 194 | execute "noautocmd " . l:split["winnr"] . "wincmd w" 195 | call g:MergetoolSetLayoutCallback(l:split) 196 | endfor 197 | 198 | if s:goto_win_with_merged_file() && exists('l:_winstate') 199 | call winrestview(l:_winstate) 200 | endif 201 | endfunction " }}} 202 | 203 | " Toggles between given and default layout 204 | function mergetool#toggle_layout(layout) " {{{ 205 | if s:current_layout !=# a:layout 206 | call mergetool#set_layout(a:layout) 207 | else 208 | call mergetool#set_layout(g:mergetool_layout) 209 | endif 210 | endfunction " }}} 211 | 212 | " Takes merged file with conflict markers, and removes them 213 | " by picking up side of the conflicts: local, remote, base 214 | function! mergetool#prefer_revision(revision) " {{{ 215 | call s:ensure_in_mergemode() 216 | 217 | silent call s:goto_win_with_merged_file() 218 | silent call s:restore_merged_file_contents() 219 | if a:revision !=# 'unmodified' 220 | silent call s:remove_conflict_markers(a:revision) 221 | endif 222 | endfunction " }}} 223 | 224 | " }}} 225 | 226 | " Private functions{{{ 227 | 228 | let s:markers = { 229 | \ 'ours': '^<<<<<<< ', 230 | \ 'theirs': '^>>>>>>> ', 231 | \ 'base': '^||||||| ', 232 | \ 'delimiter': '^=======\r\?$' } 233 | 234 | " Loads file revision in current window 235 | function! s:load_revision(revision) 236 | if a:revision ==# 'base' || a:revision ==# 'remote' || a:revision ==# 'local' 237 | 238 | " Open new buffer, put merged file contents wiht conflict markers, 239 | " remove markers and pick up right revision 240 | enew 241 | execute "setlocal fileformat=" . s:mergedfile_fileformat 242 | put = s:mergedfile_contents | 1delete _ 243 | call s:remove_conflict_markers(a:revision) 244 | setlocal nomodifiable readonly buftype=nofile bufhidden=delete nobuflisted 245 | execute "setlocal filetype=" . s:mergedfile_filetype 246 | execute "file " . a:revision 247 | elseif a:revision ==# 'BASE' || a:revision ==# 'REMOTE' || a:revision ==# 'LOCAL' 248 | 249 | " First, if run as 'git mergetool', try find buffer by name: 'BASE|REMOTE|LOCAL' 250 | " Otherwise, load revision from Git index 251 | if s:run_as_git_mergetool 252 | execute "buffer " . a:revision 253 | setlocal nomodifiable readonly 254 | else 255 | enew 256 | call s:load_revision_from_index(a:revision) 257 | setlocal nomodifiable readonly buftype=nofile bufhidden=delete nobuflisted 258 | execute "setlocal filetype=" . s:mergedfile_filetype 259 | execute "file " . a:revision 260 | endif 261 | else 262 | throw "Not supported revision: " . a:revision 263 | endif 264 | endfunction 265 | 266 | 267 | " Loads revision of unmerged file from Git's index 268 | " See https://git-scm.com/book/en/v2/Git-Tools-Advanced-Merging 269 | " Reminder on unmerged revisions stored in index stages 270 | " $ git show :1:hello.rb > hello.base.rb 271 | " $ git show :2:hello.rb > hello.ours.rb 272 | " $ git show :3:hello.rb > hello.theirs.rb 273 | function! s:load_revision_from_index(revision) 274 | 275 | let index = { 276 | \ 'BASE': 1, 277 | \ 'LOCAL': 2, 278 | \ 'REMOTE': 3 } 279 | execute printf("read !git cat-file -p :%d:%s", index[a:revision], s:mergedfile_name) 280 | silent 1delete _ 281 | endfunction 282 | 283 | " Removes conflict markers from current file, leaving one side of the conflict 284 | function! s:remove_conflict_markers(pref_revision) 285 | " Reminder on git conflict markers 286 | 287 | " <<<<<<< ours 288 | " ours pref_revision 289 | " ||||||| base 290 | " base pref_revision 291 | " ======= 292 | " theirs pref_revision 293 | " >>>>>>> theirs 294 | 295 | 296 | " Command removes range of lines from the file 297 | " g/{start_marker}/, find start of the range by given marker 298 | " .,/{end_marker}, finds end of the range by given marker 299 | let delete_range = 'g/%s/ .,/%s/ delete _' 300 | let delete_marker = 'g/%s/ delete _' 301 | 302 | if a:pref_revision ==# 'base' 303 | execute printf(delete_range, s:markers['ours'], s:markers['base']) 304 | execute printf(delete_range, s:markers['delimiter'], s:markers['theirs']) 305 | elseif a:pref_revision ==# 'local' 306 | execute printf(delete_marker, s:markers['ours']) 307 | execute printf(delete_range, s:markers['base'], s:markers['theirs']) 308 | elseif a:pref_revision ==# 'remote' 309 | execute printf(delete_range, s:markers['ours'], s:markers['delimiter']) 310 | execute printf(delete_marker, s:markers['theirs']) 311 | else 312 | throw "Not supported revision: " . a:pref_revision 313 | endif 314 | endfunction 315 | 316 | " Tells if file has conflict markers 317 | function! s:has_conflict_markers() 318 | return search(s:markers['ours'], 'w') != 0 && 319 | \ search(s:markers['theirs'], 'w') != 0 && 320 | \ search(s:markers['delimiter'], 'w') != 0 321 | endfunction 322 | 323 | function s:has_conflict_markers_in_diff3_style() 324 | return search(s:markers['base'],'w') != 0 325 | endfunction 326 | 327 | " Discard all changes in buffer, and fill it with original merged file contents 328 | function! s:restore_merged_file_contents() 329 | %delete _ | put =s:mergedfile_contents | 1delete _ 330 | endfunction 331 | 332 | " Find window with merged file and focus it 333 | " Tell if window was found 334 | function! s:goto_win_with_merged_file() 335 | let l:winnr = bufwinnr(s:mergedfile_bufnr) 336 | execute "noautocmd " . bufwinnr(s:mergedfile_bufnr) . "wincmd w" 337 | return l:winnr != -1 338 | endfunction 339 | 340 | function! s:ensure_in_mergemode() 341 | if !g:mergetool_in_merge_mode 342 | throw "Not in a merge mode" 343 | endif 344 | endfunction 345 | 346 | " }}} 347 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # vim-mergetool 2 | 3 | Efficient way of using Vim as a Git mergetool. :cake: With `vim-mergetool` you can have your cake and eat it too. Check out the demo. 4 | 5 | 6 | 7 | 8 | 9 | 10 | ## Overview 11 | 12 | `vim-mergetool` processes `MERGED` file and extracts `ours`, `theirs`, or `common` sides of a conflict by parsing conflict markers left by Git. Then it shows 2-way diff between `ours` and `theirs` versions, with raw conflict markers being already removed. 13 | 14 | Unlike simply comparing between `LOCAL` and `REMOTE` history revisions, it takes over where automatic Git merge algorithm gives up. Diffs are present only where Git cannot automatically resolve conflicts, and you're not distracted with diff highlighting of already resolved hunks. 15 | 16 | In a screenshot below, `MERGED` file is on the left with conflict markers already removed with optimistic assumption of picking up changes from `ours` side. Then it's compared to the `REMOTE` branch, but focusing only on conflicts. This solution highlights the actual merge conflicts instead of all the diffs. 17 | 18 | ![Default 2 way diff layout between merged file and remote revision](./screenshots/mergetool_mr_2way_default_layout.png) 19 | 20 | Plus, to resolve the conflict you don't need to edit conflict markers directly - just pick either side of a conflict using `:diffget` and `:diffput` commands. 21 | 22 | This plugin was initially inspired by [whiteinge/diffconflicts](https://github.com/whiteinge/diffconflicts). Check out [how this plugin is different from existing solutions](#how-is-it-different-from-x). 23 | 24 | ## Minimal working example (TL;DR) 25 | 26 | Use your favorite Vim plugin manager. 27 | 28 | Minimal working configuration using [vim-plug](https://github.com/junegunn/vim-plug). 29 | 30 | ```vim 31 | set nocompatible 32 | filetype plugin indent on 33 | 34 | call plug#begin('~/.vim/plugged') 35 | Plug 'samoshkin/vim-mergetool' 36 | call plug#end() 37 | 38 | let g:mergetool_layout = 'mr' 39 | let g:mergetool_prefer_revision = 'local' 40 | ``` 41 | 42 | In Vim, open a file with conflict markers, and start mergetool. 43 | 44 | 45 | ``` 46 | :MergetoolStart 47 | ``` 48 | 49 | `vim-mergetool` would show 2-way diff in a new tab with `$MERGED` file on the left. By default, all conflicts are already resolved by picking up `ours/LOCAL` version. You don't need to edit raw conflict markers manually. Either leave hunk as is, or pick `theirs/REMOTE` version with `:diffget` from the right, or edit hunk manually. 50 | 51 | Once done, quit merge tool: 52 | 53 | ``` 54 | :MergetoolStop 55 | ``` 56 | 57 | There's a single `:MergetoolToggle` command can both start and stop merge mode. Plus, you can set up a key mapping to toggle merge mode: 58 | 59 | ```vim 60 | nmap mt (MergetoolToggle) 61 | ``` 62 | 63 | This example only scratches the surface of what `vim-mergetool` can do. Keep reading, if you need more features/customization. 64 | 65 | 66 | ### Conflict markers format 67 | 68 | **[ASSUMPTION]**: `vim-mergetool` expects conflict markers in a `MERGED` file to include common `base` ancestor version as well. This is called `diff3` conflict style. 69 | 70 | ``` 71 | " <<<<<<< HEAD 72 | " ours/local revision 73 | " ||||||| base 74 | " common base revision 75 | " ======= 76 | " theirs/remote revision 77 | " >>>>>>> feature 78 | ``` 79 | 80 | If you get *"Conflict markers miss common base revision"* error message, put the following in your `~/.gitconfig` to use [diff3](https://git-scm.com/docs/git-config#Documentation/git-config.txt-mergeconflictStyle) conflict style as a default: 81 | 82 | ```ini 83 | [merge] 84 | conflictStyle = diff3 85 | ``` 86 | 87 | If something goes absolutely wrong, you can always reset conflict markers in a file to their initial state. It's safe to do it only during ongoing merge, otherwise you'd overwrite file in a working tree with version from index. 88 | 89 | ``` 90 | git checkout --conflict=diff3 {file} 91 | ``` 92 | 93 | ## Features 94 | 95 | - Flexible layouts. You're not limited to default 2-way diff layout. You can use 3-way diff layout, or even setup window of 4 splits. Both horizontal and vertical splits are supported, and mix thereof. 96 | - Toggle between layouts during merge. You can have several layouts and toggle between them during merge. For example, you're using 2-way diff by default, but sometimes you want to quickly recall what's the state of a diff in the `BASE` revision, but don't want to keep 3rd `BASE` split constantly opened. 97 | - Customize layout splits as you like: resize, turn off syntax highlighting, turn off diff mode. 98 | - Choose preferred conflict side. `ours` side is picked up by default but you can also choose `ours`, `theirs` or `base` side of a conflict for the `MERGED` file, or work with raw conflict markers. 99 | - Conventional `LOCAL`, `REMOTE`, `BASE` history revisions are available to compare to as well. 100 | - Can be run as a `git mergetool`, or by opening a file with conflict markers from the running Vim instance. 101 | - Prompts whether merge was successful on quit. If not, rollbacks changes and report non-zero exit status code when run as a `git mergetool`. 102 | - Smart diff exchange commands. Tell direction of a window to `diffget` or `diffput` instead of specifying a buffer number. Especially handy for 3-way diffs. Not limited to merge conflict scenarios, can be used for regular diffs. 103 | - Can tell if we're in merge mode right now. Useful for showing some sort of indicator in a status line. 104 | 105 | **NOTE**: `vim-mergetool` does not set up any key mappings for you. It justs exports a handful of commands and `` mappings. You're free to set up key mappings in your `vimrc` as you'd like. 106 | 107 | ### Preferred conflict side 108 | `vim-mergetool` removes conflict markers from `MERGED` file, and picks up `ours/local` side of a conflict by default. If you prefer another side of a conflict: 109 | 110 | ```vim 111 | " possible values: 'local' (default), 'remote', 'base' 112 | let g:mergetool_prefer_revision = 'remote' 113 | ``` 114 | 115 | If you don't want `vim-mergetool` to process `MERGED` file and remove raw conflict markers: 116 | 117 | ```vim 118 | let g:mergetool_prefer_revision = 'unmodified' 119 | ``` 120 | 121 | Alternatively, you can start with `local` or `unmodified` revision, and change your mind later during merge process by running one of these commands: 122 | 123 | ```vim 124 | :MergetoolPreferLocal 125 | :MergetoolPreferRemote 126 | ``` 127 | 128 | ### Available revisions to compare 129 | 130 | 2-way diff between `local` and `remote` versions derived from conflict markers is a sane default, but you might want to compare `MERGED` file against other revisions: 131 | - `LOCAL`, current branch HEAD. 132 | - `REMOTE`, HEAD of the branch we're going to merge 133 | - `BASE`, common ancestor of two branches, i.e. `git merge-base branchX branchY` 134 | - `local`, `remote`, `base` (in lowercase), those are revisions derived from `MERGED` file by picking up either side of a conflict from conflict markers 135 | 136 | 137 | ### Layout 138 | 139 | `vim-mergetool` defaults to two vertical splits layout with `MERGED` file on the left, and `remote` revision on the right. `MERGED` file is processed according to `g:mergetool_prefer_revision` setting as described above. 140 | 141 | ```vim 142 | " (m) - for working tree version of MERGED file 143 | " (r) - for 'remote' revision 144 | let g:mergetool_layout = 'mr' 145 | ``` 146 | 147 | If you want to use 3-way diff layout as a default, with a `base` revision in the leftmost split: 148 | 149 | ```vim 150 | let g:mergetool_layout = 'bmr' 151 | ``` 152 | 153 | ![3 way diff vertical split layout](./screenshots/bmr_3splits_layout.png) 154 | 155 | To show usual `REMOTE`, `LOCAL`, `BASE` history revisions, use uppercase characters: 156 | 157 | ```vim 158 | let g:mergetool_layout = 'LmR' 159 | ``` 160 | 161 | By the way, this setup is pretty much same to what [vim-fugitive](https://github.com/tpope/vim-fugitive) `:Gdiff` does, except that conflict markers are already removed. You can use `g:mergetool_prefer_revision='unmodified'` to replicate vim-fugitive completely. Indeed, `vim-mergetool` is flexible enough to replicate any existing vim+merge solution. 162 | 163 | Vertical splits are used by default. If you prefer working with horizontal splits: 164 | 165 | ```vim 166 | let g:mergetool_layout = 'm,r' 167 | ``` 168 | 169 | You can mix both approaches. For example, show `MERGED` file and `remote` revision in vertical splits as usual, and have horizontal split with `base` revision at the bottom. 170 | 171 | ```vim 172 | let g:mergetool_layout = 'mr,b' 173 | ``` 174 | 175 | ![Horizontal and vertical mixed layout](./screenshots/bmr_mixed_layout.png) 176 | 177 | 178 | ### Toggle layout during merge 179 | You are not limited to single possible layout. You can switch easily between different layouts during merge process. 180 | 181 | For example, you can start with 2-way diff layout, and then temporarily toggle additional split with `base` revision on the left or at the bottom. Or hide `MERGED` file altogether and review `LOCAL`, `BASE` and `REMOTE` history revisions. 182 | 183 | 184 | ```vim 185 | " In 'vimrc' 186 | " Default layout 187 | let g:mergetool_layout = 'mr' 188 | 189 | " Later, during merge process: 190 | 191 | " View 'base' revision on the left 192 | :MergetoolToggleLayout bmr 193 | 194 | " View 'base' revision in horizontal split at the bottom 195 | :MergetoolToggleLayout mr,b 196 | 197 | " View history revisions, and hide 'MERGED' file altogether 198 | :MergetoolToggleLayout LBR 199 | ``` 200 | 201 | ![Switching layouts](./screenshots/toggle_layouts.png) 202 | 203 | In addition to commands, you can set up key mappings for your most common layouts: 204 | 205 | ```vim 206 | nnoremap mb :call mergetool#toggle_layout('mr,b') 207 | ``` 208 | 209 | ### Advanced layout customization 210 | 211 | If you want to further tweak layout or change settings of individual splits, define the callback function, which is called when layout is changed. 212 | 213 | Example. When layout is `mr,b`, I want the `base` horizontal split to be pulled of a diff mode and have syntax highlighting enabled. Also, I want it to reduce its height. 214 | 215 | ```vim 216 | function s:on_mergetool_set_layout(split) 217 | if a:split["layout"] ==# 'mr,b' && a:split["split"] ==# 'b' 218 | set nodiff 219 | set syntax=on 220 | 221 | resize 15 222 | endif 223 | endfunction 224 | 225 | let g:MergetoolSetLayoutCallback = function('s:on_mergetool_set_layout') 226 | ``` 227 | 228 | Callback is called for each split in the layout, with a split being passed as a callback argument. 229 | 230 | ``` 231 | { 232 | 'layout': 'mb,r', # current layout 233 | 'split': 'b', # current split 234 | 'filetype': 'javascript', # file type of MERGED file 235 | 'bufnr': 2, # buffer number of current split 236 | 'winnr': 5 # window number of current split 237 | } 238 | ``` 239 | 240 | Example. I want to turn off syntax and spell checking highlighting for all splits, so it doesn't distract me from diff highlighting. 241 | 242 | 243 | ```vim 244 | function s:on_mergetool_set_layout(split) 245 | set syntax=off 246 | set nospell 247 | endfunction 248 | 249 | let g:MergetoolSetLayoutCallback = function('s:on_mergetool_set_layout') 250 | ``` 251 | 252 | Here's the end result: 253 | 254 | ![Layout advanced customization](./screenshots/layout_advanced_customization.png) 255 | 256 | 257 | ### Running as a `git mergetool` 258 | 259 | `vim-mergetool` can be configured to run as a `git mergetool`. In your `~/.gitconfig`: 260 | 261 | ```ini 262 | [merge] 263 | tool = vim_mergetool 264 | conflictstyle = diff3 265 | 266 | [mergetool "vim_mergetool"] 267 | cmd = vim -f -c "MergetoolStart" "$MERGED" "$BASE" "$LOCAL" "$REMOTE" 268 | trustExitCode = true 269 | ``` 270 | 271 | Git detects whether merge was successful or not in two ways: 272 | - When `trustExitCode = false`, checks if `MERGED` file was modified. 273 | - When `trustExitCode = true`, checks exit code of merge tool process. 274 | 275 | `vim-mergetool` supports both options. On quit, if merge was unsuccessful, it both discards any unsaved changes to buffer without touching file's `ctime` and returns non-zero exit code. 276 | 277 | 278 | ### Running directly from running Vim instance 279 | You can enter and exit merge mode from running Vim instance by opening a file with conflict markers, and running one of the commands: 280 | 281 | ```vim 282 | :MergetoolStart 283 | :MergetoolStop 284 | :MergetoolToggle 285 | ``` 286 | 287 | You can set up a key mapping to toggle merge mode: 288 | 289 | ```vim 290 | nmap mt (MergetoolToggle) 291 | ``` 292 | 293 | When exiting merge mode, if merge was unsuccessful, `vim-mergetool` would discard changes to merged file and rollback to a buffer state as it were right before starting a new merge. 294 | 295 | Unlike running as a `git mergetool`, `LOCAL`, `REMOTE` and `BASE` history revisions are not passed from the outside. In this mode, `vim-mergetool` extracts them from the numbered stages of Git index. 296 | 297 | ```bash 298 | $ git cat-file -p :1:{file} > {file}.base 299 | $ git cat-file -p :2:{file} > {file}.local 300 | $ git cat-file -p :3:{file} > {file}.remote 301 | ``` 302 | 303 | **ASSUMPTION:** Therefore, it's assumed that a git merge is in progress, and `cwd` of running Vim instance is set to repository root dir. 304 | 305 | 306 | ### Smart diff exchange commands 307 | 308 | Vim's `:diffget` and `:diffput` commands are convenient and unambiguous as soon as you have only two buffers in diff mode. If you prefer 3-way diff, you're out of lucky, as you need to explicitly tell the buffer number you want to exchange diff with. 309 | 310 | `vim-mergetool` comes with `DiffExchange` commands and mapping, that accepts direction of a diff movement: "left", "right", "up", "down". You can set up your own key mappings for diff mode only: 311 | 312 | ```vim 313 | nmap &diff? '(MergetoolDiffExchangeLeft)' : '' 314 | nmap &diff? '(MergetoolDiffExchangeRight)' : '' 315 | nmap &diff? '(MergetoolDiffExchangeDown)' : '' 316 | nmap &diff? '(MergetoolDiffExchangeUp)' : '' 317 | ``` 318 | 319 | Commands are available as well: 320 | 321 | ```vim 322 | :MergetoolDiffExchangeLeft 323 | :MergetoolDiffExchangeRight 324 | :MergetoolDiffExchangeDown 325 | :MergetoolDiffExchangeUp 326 | ``` 327 | 328 | `DiffExchange` logic runs either `:diffget` or `:diffput` with a right buffer number of adjacent window, depending on: 329 | - given direction 330 | - whether window in opposite direction exists or not 331 | 332 | ![Diff exchange](./screenshots/diff_exchange.png) 333 | 334 | It's easier to explain with example. 335 | 336 | Suppose, you have 3 split layout: `MERGED` file in the middle, `base` and `remote` revisions are on the sides. Typically, the middle one with a `MERGED` file is an active split. You navigate from hunk to hunk, and decide what to do with a conflict: leave as is, or pick version from left/right splits. 337 | - `` would `diffget` change from the right split into the middle one. If you imagine the diff movement - it goes from right to the left. 338 | - `` would `diffget` change from the left split into the middle one. If you imagine the diff movement - it goes from left to the right. 339 | 340 | If the rightmost split were the active one: 341 | - `` would `diffput` change from the current split into the middle one. As soon as there is no adjacent window on the right to get change from, we invert `diffget` operation into `diffput`. 342 | - `` would `diffget` change from middle split. 343 | 344 | Same logic applies to "up" and "down" directions. Useful if you prefer horizontal splits. 345 | 346 | **Conclusion**: despite how many splits are opened and what's the layout, you don't need to wrap your head around `diffput` vs `diffget` semantics, and you don't need to figure out correct buffer numbers manually. You just tell desired diff movement direction, and `vim-mergetool` handles the details for you. 347 | 348 | **Limitation**: `DiffExchange` commands work only in normal mode, and do not support visual mode and working with line ranges. 349 | 350 | `DiffExchange` functionality is not specific to resolving merge conflicts, and can be used for regular diffs. 351 | 352 | If you like `` mappings from the snippet above, you might also want to map `` and `` keys to navigate diffs, instead of default `[c` and `]c` mappings. They're not used anyway, since you're using `h,j,k,l` for movements, are you? ;-) 353 | 354 | ```vim 355 | nmap &diff ? '[c' : '' 356 | nmap &diff ? ']c' : '' 357 | ``` 358 | 359 | 360 | ### Merge mode detection 361 | You can detect whether you're in merge mode now, by inspecting `g:mergetool_in_merge_mode` variable. 362 | 363 | It can be helpful to show indicator in a status line. Example for [vim-airline](https://github.com/vim-airline/vim-airline): 364 | 365 | ```vim 366 | function! AirlineDiffmergePart() 367 | if get(g:, 'mergetool_in_merge_mode', 0) 368 | return '↸' 369 | endif 370 | 371 | if &diff 372 | return '↹' 373 | endif 374 | 375 | return '' 376 | endfunction 377 | 378 | call airline#parts#define_function('_diffmerge', 'AirlineDiffmergePart') 379 | call airline#parts#define_accent('_diffmerge', 'bold') 380 | 381 | let g:airline_section_z = airline#section#create(['_diffmerge', ...other_parts]) 382 | ``` 383 | 384 | ![Status line indicator](./screenshots/airline_merge_indicator.png) 385 | 386 | ### Quitting merge mode 387 | 388 | When exiting merge mode, `vim-mergetool` would prompt you whether merge was successful. If not, it will rollback changes to the buffer, will not save `MERGED` file to disk, and exit with non-zero code, when running as a `git mergetool`. 389 | 390 | ![Quit prompt](./screenshots/quit_prompt.png) 391 | 392 | You can either issue `:MergetoolStop` or `:MergetoolToggle` commands, or use dedicated mapping. 393 | 394 | Yet another approach, which I prefer in my personal `vimrc`, is having a `q` key mapped to context-aware `QuitWindow()` function. It detects whether we're in merge mode, and runs `:MergetoolStop` command, or just uses normal "quit" command otherwise. 395 | 396 | ```vim 397 | function s:QuitWindow() 398 | 399 | " If we're in merge mode, exit 400 | if get(g:, 'mergetool_in_merge_mode', 0) 401 | call mergetool#stop() 402 | return 403 | endif 404 | 405 | if &diff 406 | " Quit diff mode intelligently... 407 | endif 408 | 409 | quit 410 | endfunction 411 | 412 | command QuitWindow call s:QuitWindow() 413 | nnoremap q :QuitWindow 414 | ``` 415 | 416 | 417 | ## How is it different from X? 418 | Most merge tools use 3-way diff approach by showing several split windows, that include `LOCAL` revision (current branch), `REMOTE` revision (branch we're going to merge), `BASE` revision (common base ancestor), and working tree version of `MERGED` file with or without conflict markers. 419 | 420 | Here is the same merge scenario opened with a default `vimdiff` as a mergetool: 421 | ![Default vimdiff mergetool](./screenshots/vimdiff_default_tool.png) 422 | 423 | While 3-way diff paradigm is superior to 2-way diff for merging purposes, it does not fit Vim well: 424 | 425 | **Disadvantages**: 426 | - When several revisions are compared at the same time (i.e. `:diffthis` in all buffers), Vim highlights every diff between each of those files. Usually that means every possible change between BASE-LOCAL-REMOTE-MERGED files, including those which are not relevant to conflict resolution. 427 | - It highlights even those hunks, which were already automatically resolved by `git merge-file`. It distracts you from focusing purely on unresolved conflicts. Indeed, I don't want to care about already resolved conflicts. 428 | - Limited window width when three vertical splits are opened. Forces you to scroll horizontally, or wrap lines. Unless you have enough screen width, it's difficult to quickly grasp changes when window width is only ~50 columns or so. Usually, it's not easy to tweak predefined layout. 429 | - Forces you to pick up conflict side by directly editing conflict markers, instead of choosing change from the left or the right. 430 | - `:diffget` and `:diffput` Vim commands are convenient only when there're two split windows, otherwise they become ambiguous and you need to tell them the target buffer number, which is a real showstopper. No one wants to think on "What's the Vim's buffer number of the window on the right/left?", when you're already pulling your hair trying to resolve conflicts from long-running "feature" branch. 431 | 432 | #### Existing solutions 433 | 434 | - default `vimdiff` merge tool. Shows layout with 3 vertical splits: `LOCAL`, `REMOTE`, `BASE` revisions, and the horizontal split at the bottom with a `MERGED` file, containing raw conflict markers. 435 | - [vim-fugitive](https://github.com/tpope/vim-fugitive) `:Gdiff` command, which automatically detects conflict markers in a file and switches into 3-way diff. Shows 3 vertical splits: `LOCAL`, `REMOTE` revisions and `MERGED` file in the middle with raw conflict markers. See this [reddit comment](https://www.reddit.com/r/vim/comments/b0jjgw/github_samoshkinvimmergetool_efficient_way_of/eif6oio/) on difference between [vim-fugitive](https://github.com/tpope/vim-fugitive) and this plugin. 436 | - [whiteinge/diffconflicts](https://github.com/whiteinge/diffconflicts). Parses `MERGED` file and removes conflict markers to pick up one side of a conflict. Default to 2-split layout with `local` and `remote` revisions. `vim-mergetool` uses the same idea, plus brings many additional features. 437 | - [sjl/splice.vim](https://github.com/sjl/splice.vim). Haven't yet had experience with it. 438 | - Drop using Vim as a mergetool. Use some GUI program, like [DiffMerge](https://sourcegear.com/diffmerge/) or [Kdiff3](http://kdiff3.sourceforge.net/). Better to use both Vim and some GUI tool as a backup though. 439 | 440 | --------------------------------------------------------------------------------