diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 4219eb2..071b64d 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -151,11 +151,15 @@ jobs:
- name: Check out repository code
uses: actions/checkout@v4
+ # The neovim PPA has no build for every Ubuntu ubuntu-latest moves to:
+ # use the pinned release like the Test job
- name: Install vim and neovim
run: |
- sudo add-apt-repository ppa:neovim-ppa/stable -y
sudo apt-get update
- sudo apt-get -y install vim neovim
+ sudo apt-get -y install vim
+ curl -fsSL https://github.com/neovim/neovim/releases/download/v0.11.6/nvim-linux-x86_64.tar.gz \
+ | sudo tar -xz -C /opt
+ echo /opt/nvim-linux-x86_64/bin >> "$GITHUB_PATH"
- name: Clone vim-test
run: |
diff --git a/README.md b/README.md
index 25b4bde..68a06ab 100644
--- a/README.md
+++ b/README.md
@@ -197,6 +197,9 @@ let g:vim_project_local_config = {
| s | shelve: save the changes as a patch and revert them |
| S | unshelve a shelf or one of its files |
| u / p / P | pull, push, pull then push, in the background |
+| A | abort the merge, rebase, cherry-pick or revert in progress |
+
+Merge conflicts: a pull or branch merge that stops on conflicts opens the Unmerged changelist. Enter on a file opens a three-pane merge window, yours | result | theirs, with each conflict colored by state. Press < yours, > theirs, = both, e edit, u undo, ]c / [c move, q to accept (write and `git add`) once all are resolved, Q to discard. After the last file it offers to commit the merge or continue the rebase.
### Git log, file history, branches and tags
@@ -209,7 +212,7 @@ let g:vim_project_local_config = {
- `:ProjectGitLog` searches commits by message, author or hash, with the changed files of the selected one beside it.
- `:ProjectGitFileHistory` shows the commits of the current file, or of the selected lines, with their diffs.
-- `:ProjectGitBranch` switches, creates and merges branches.
+- `:ProjectGitBranch` switches, creates and merges branches. It fetches remotes in the background and refreshes the list when done.
- `:ProjectGitTag` checks out and creates tags.
| List | Key | Action |
diff --git a/autoload/project/config.vim b/autoload/project/config.vim
index edd1812..6672a5b 100644
--- a/autoload/project/config.vim
+++ b/autoload/project/config.vim
@@ -38,6 +38,7 @@ let s:default = {
\'new_project_base': '',
\'new_tasks_post_cmd': '',
\'commit_message': '',
+ \'git_branch_fetch': 1,
\'find_replace_separator': ' => ',
\'debug': 0,
\}
@@ -105,6 +106,20 @@ let s:default.git_local_changes_mappings = {
\'force_push': '',
\'shelf': 's',
\'unshelf': 'S',
+ \'abort_merge': 'A',
+ \}
+let s:default.git_merge_mappings = {
+ \'take_yours': '<',
+ \'take_theirs': '>',
+ \'take_both': '=',
+ \'undo': 'u',
+ \'edit': 'e',
+ \'next': ']c',
+ \'previous': '[c',
+ \'all_yours': 'L',
+ \'all_theirs': 'R',
+ \'finish': 'q',
+ \'quit': 'Q',
\}
let s:default.file_open_types = {
\'': 'edit',
@@ -126,6 +141,7 @@ let s:merge_keys = [
\'git_diff_mappings',
\'git_changes_mappings',
\'git_local_changes_mappings',
+ \'git_merge_mappings',
\]
" Options a project's init.vim may set. Lists and dicts extend the global
diff --git a/autoload/project/git.vim b/autoload/project/git.vim
index 1feef42..4a38464 100644
--- a/autoload/project/git.vim
+++ b/autoload/project/git.vim
@@ -42,6 +42,8 @@ let s:changed_files = []
let s:untracked_files = []
let s:staged_files = []
let s:unmerged_files = []
+" Statuses of git status --porcelain for unmerged paths
+let s:unmerged_status = ['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU']
let s:commit_files = []
let s:file_regexp = '|\zs.*\ze|'
let s:directory_regexp = '| \zs.*$'
@@ -899,6 +901,11 @@ function! s:OpenFolderOrFile()
return
endif
+ if index(s:unmerged_files, 'U '.file) != -1
+ call project#git_merge#Open(file)
+ return
+ endif
+
let diff_file = s:TryGetDiffFile(file)
wincmd k
if empty(diff_file)
@@ -1647,7 +1654,12 @@ function! s:ParseGitStatus()
let status = strpart(line, 0, 2)
let filepath = strpart(line, 3)
- if status[0] != ' ' && status[0] != '?' && status[0] != 'U' " Staged files
+ if index(s:unmerged_status, status) != -1 " Merge conflicts
+ call add(s:unmerged_files, 'U '.filepath)
+ continue
+ endif
+
+ if status[0] != ' ' && status[0] != '?' " Staged files
call add(s:staged_files, status[0].' '.filepath)
endif
@@ -1658,10 +1670,6 @@ function! s:ParseGitStatus()
if status == '??' " Untracked files
call add(s:untracked_files, '? '.filepath)
endif
-
- if status == 'UU' " Merge conflicts
- call add(s:unmerged_files, 'U '.filepath)
- endif
endfor
endfunction
@@ -1795,6 +1803,7 @@ function! s:SetupChangelistBuffer()
call s:AddVisualMapping(mappings.shelf, 'ShelfFile()')
call s:AddMapping(mappings.unshelf, 'UnshelfFile()')
call s:AddVisualMapping(mappings.unshelf, 'UnshelfFile()')
+ call s:AddMapping(mappings.abort_merge, 'project#git_merge#Abort()')
hi default DiffBufferModify ctermfg=3 guifg=#b58900
hi default DiffBufferAdd ctermfg=2 guifg=#719e07
@@ -2093,7 +2102,9 @@ function! s:OnGitJobDone(cmd, done_message, OnSuccess, ctx, status)
let s:git_job = v:null
let lines = filter(copy(a:ctx.lines), '!empty(v:val)')
if a:status != 0
- call project#WarnCommandOutput(a:cmd, lines)
+ if !project#git_merge#ShowConflicts()
+ call project#WarnCommandOutput(a:cmd, lines)
+ endif
return
endif
diff --git a/autoload/project/git_branch.vim b/autoload/project/git_branch.vim
index c22940b..701ab31 100644
--- a/autoload/project/git_branch.vim
+++ b/autoload/project/git_branch.vim
@@ -25,6 +25,13 @@ function! s:Highlight(state)
endfunction
function! s:Init(input)
+ let s:refs = s:GetRefs()
+ let s:list = s:LoadBranches()
+ call s:Update(a:input)
+ call s:StartFetch()
+endfunction
+
+function! s:LoadBranches()
let format = join(['%(HEAD)', '%(refname:short)', '%(upstream:short)',
\'%(contents:subject)', '%(authorname)', '%(committerdate:relative)'], s:splitter)
let cmd = 'git branch -a --format='.shellescape(format)
@@ -36,8 +43,87 @@ function! s:Init(input)
endif
call insert(branches, s:CreateItem(' ', s:item_splitter))
call insert(branches, s:CreateItem('+', s:item_new_branch))
- let s:list = s:GetTabulatedList(branches)
- call s:Update(a:input)
+ return s:GetTabulatedList(branches)
+endfunction
+
+" ---------------------------------------------------------------- fetch
+
+" Remote branches are only as new as the last fetch: fetch in the background
+" each time the list opens, then show them again if it is still open. git
+" cannot ask for credentials without a terminal, so it fails instead
+let s:fetch_job = v:null
+let s:refs = []
+
+function! s:StartFetch()
+ if !project#config#Get('git_branch_fetch') || s:fetch_job isnot v:null
+ \|| !(exists('*job_start') || exists('*jobstart'))
+ \|| empty(project#RunShellCmd('git remote', 0))
+ return
+ endif
+
+ let ctx = {'lines': []}
+ let cmd = [&shell, &shellcmdflag, 'git fetch --all --prune --quiet']
+ let env = {'GIT_TERMINAL_PROMPT': '0'}
+ if exists('*job_start')
+ let s:fetch_job = job_start(cmd, {
+ \'cwd': $vim_project,
+ \'env': env,
+ \'in_io': 'null',
+ \'out_cb': {_, line -> add(ctx.lines, line)},
+ \'err_cb': {_, line -> add(ctx.lines, line)},
+ \'exit_cb': function('s:OnVimFetchExit', [ctx]),
+ \})
+ else
+ let s:fetch_job = jobstart(cmd, {
+ \'cwd': $vim_project,
+ \'env': env,
+ \'stdin': 'null',
+ \'stdout_buffered': v:true,
+ \'stderr_buffered': v:true,
+ \'on_stdout': {_, data, __ -> extend(ctx.lines, data)},
+ \'on_stderr': {_, data, __ -> extend(ctx.lines, data)},
+ \'on_exit': {_, status, __ -> s:OnFetched(status, ctx)},
+ \})
+ endif
+endfunction
+
+" exit_cb may run before the output was read
+function! s:OnVimFetchExit(ctx, job, status)
+ call timer_start(10, {-> s:OnFetched(a:status, a:ctx)})
+endfunction
+
+function! s:OnFetched(status, ctx)
+ let s:fetch_job = v:null
+ if !project#list#IsOpen('GIT_BRANCH')
+ return
+ endif
+ if a:status != 0
+ let lines = filter(copy(a:ctx.lines), '!empty(v:val)')
+ call project#list#SetInfoOnClose('git fetch failed: '.get(lines, 0, 'exit '.a:status))
+ return
+ endif
+
+ " Redraw only when a branch moved, not to flash the list for nothing
+ let refs = s:GetRefs()
+ if refs == s:refs
+ return
+ endif
+ let list = s:LoadBranches()
+ " Two items are New Branch and the splitter: keep the list on an error
+ if len(list) <= 2
+ return
+ endif
+ let s:refs = refs
+ let s:list = list
+ call s:Update(project#list#State().user_input)
+ call project#list#RedrawInputLine()
+endfunction
+
+" Branches and the commits they point to, to tell whether a fetch changed any
+function! s:GetRefs()
+ let format = shellescape('%(refname) %(objectname) %(upstream)')
+ let cmd = 'git for-each-ref --format='.format.' refs/heads refs/remotes'
+ return project#RunShellCmd(cmd, 0)
endfunction
function! s:Update(input)
@@ -111,8 +197,11 @@ function! s:MergeBranch(state)
let cmd = 'git merge '.shellescape(name)
- call project#RunShellCmd(cmd)
+ let output = project#RunShellCmd(cmd, 0)
if v:shell_error
+ " Once the list is closed
+ call timer_start(0, {-> project#git_merge#ShowConflicts()
+ \|| project#WarnCommandOutput(cmd, output)})
return 'close'
endif
let a:state.offset = 0
diff --git a/autoload/project/git_merge.vim b/autoload/project/git_merge.vim
new file mode 100644
index 0000000..556e68b
--- /dev/null
+++ b/autoload/project/git_merge.vim
@@ -0,0 +1,724 @@
+" Resolve the merge conflicts of one file in a three-pane window, like the
+" Merge dialog of IntelliJ IDEA:
+"
+" yours (left) | result (middle) | theirs (right)
+"
+" The file is split into text and conflicts. Each conflict has a state:
+" unresolved, left, right, both or manual. The panes are drawn from this
+" model, with filler lines so that the conflicts line up, and signs color
+" each conflict by its state. All panes are read-only: a choice is made by a
+" key on the current conflict and can always be changed or undone. The file
+" is written only on finish, once every conflict is resolved
+
+let s:left_buffer = 'vim_project_merge_yours'
+let s:result_buffer = 'vim_project_merge_result'
+let s:right_buffer = 'vim_project_merge_theirs'
+let s:edit_buffer = 'vim_project_merge_edit'
+let s:unresolved_text = '(unresolved)'
+let s:help_buffer = 'vim_project_merge_keys'
+
+" The file being merged: {'file', 'segments', 'left_title', 'right_title'}.
+" A segment is {'type': 'text', 'lines'} or {'type': 'conflict', 'left',
+" 'base', 'right', 'state', 'result'}
+let s:merge = {}
+" Per segment {'start', 'height'} of the panes, set on rendering
+let s:layout = []
+
+" ---------------------------------------------------------------- state
+
+" What git is in the middle of, from the files in the git directory. For a
+" rebase git's "ours" is the branch being rebased onto, so yours is stage 3
+function! project#git_merge#GetState()
+ let git_dir = get(project#RunShellCmd('git rev-parse --absolute-git-dir', 0), 0, '')
+ if empty(git_dir)
+ return {}
+ endif
+
+ if isdirectory(git_dir.'/rebase-merge') || isdirectory(git_dir.'/rebase-apply')
+ return {'name': 'rebase', 'yours': 3, 'theirs': 2,
+ \'continue': 'git -c core.editor=true rebase --continue',
+ \'abort': 'git rebase --abort'}
+ endif
+ for [name, head] in [['merge', 'MERGE_HEAD'], ['cherry-pick', 'CHERRY_PICK_HEAD'], ['revert', 'REVERT_HEAD']]
+ if filereadable(git_dir.'/'.head)
+ let continue = name == 'merge'
+ \? 'git commit --no-edit'
+ \: 'git -c core.editor=true '.name.' --continue'
+ return {'name': name, 'head': head, 'yours': 2, 'theirs': 3,
+ \'continue': continue, 'abort': 'git '.name.' --abort'}
+ endif
+ endfor
+ return {}
+endfunction
+
+function! project#git_merge#GetUnmergedFiles()
+ return project#RunShellCmd('git diff --name-only --diff-filter=U', 0)
+endfunction
+
+" Panes titles: where yours and theirs come from
+function! s:GetTitles(state)
+ let head = s:Describe('HEAD')
+ if get(a:state, 'name', '') == 'rebase'
+ let rebase_head = s:Describe('REBASE_HEAD')
+ return ['Yours (being rebased) '.rebase_head, 'Upstream (rebasing onto) '.head]
+ endif
+ if has_key(a:state, 'head')
+ return ['Yours '.head, 'Theirs '.s:Describe(a:state.head)]
+ endif
+ return ['Yours '.head, 'Theirs']
+endfunction
+
+" Branch name or short hash of a revision, like 'main a1b2c3d'
+function! s:Describe(rev)
+ let rev = shellescape(a:rev)
+ let hash = get(project#RunShellCmd('git rev-parse --short '.rev, 0), 0, '')
+ if empty(hash)
+ return ''
+ endif
+ if a:rev == 'HEAD'
+ let name = get(project#RunShellCmd('git branch --show-current', 0), 0, '')
+ else
+ let name = get(project#RunShellCmd('git name-rev --name-only --no-undefined '.rev, 0), 0, '')
+ endif
+ return empty(name) ? hash : name.' '.hash
+endfunction
+
+" ---------------------------------------------------------------- model
+
+" Content of one stage of the file in the index, v:null if it has none, like
+" the side that deleted the file
+function! s:ReadStage(file, stage)
+ let cmd = 'git show :'.a:stage.':'.shellescape(a:file)
+ let lines = project#RunShellCmd(cmd, 0)
+ return v:shell_error ? v:null : lines
+endfunction
+
+" Split the output of git merge-file --diff3 into text and conflicts
+function! s:ParseMergeOutput(lines)
+ let segments = []
+ let text = []
+ let conflict = {}
+ let part = ''
+ for line in a:lines
+ if empty(part) && line =~# '^<<<<<<< '
+ if !empty(text)
+ call add(segments, {'type': 'text', 'lines': text})
+ let text = []
+ endif
+ let conflict = {'type': 'conflict', 'left': [], 'base': [], 'right': [],
+ \'state': '', 'result': []}
+ let part = 'left'
+ elseif part == 'left' && line =~# '^||||||| '
+ let part = 'base'
+ elseif (part == 'left' || part == 'base') && line =~# '^=======$'
+ let part = 'right'
+ elseif part == 'right' && line =~# '^>>>>>>> '
+ call add(segments, conflict)
+ let part = ''
+ elseif empty(part)
+ call add(text, line)
+ else
+ call add(conflict[part], line)
+ endif
+ endfor
+ if !empty(text)
+ call add(segments, {'type': 'text', 'lines': text})
+ endif
+ return segments
+endfunction
+
+function! s:BuildSegments(left, base, right)
+ let dir = tempname()
+ call mkdir(dir, 'p')
+ let paths = []
+ for [name, lines] in [['yours', a:left], ['base', a:base], ['theirs', a:right]]
+ let path = dir.'/'.name
+ call writefile(lines, path)
+ call add(paths, shellescape(path))
+ endfor
+ let cmd = 'git merge-file -p --diff3 -L yours -L base -L theirs '.join(paths, ' ')
+ " It exits with the number of conflicts
+ let output = systemlist(cmd)
+ call delete(dir, 'rf')
+ return s:ParseMergeOutput(output)
+endfunction
+
+function! s:Conflicts()
+ return filter(copy(s:merge.segments), 'v:val.type == "conflict"')
+endfunction
+
+function! s:CountUnresolved()
+ return len(filter(s:Conflicts(), 'empty(v:val.state)'))
+endfunction
+
+function! s:GetResultLines()
+ let lines = []
+ for segment in s:merge.segments
+ call extend(lines, segment.type == 'text' ? segment.lines : segment.result)
+ endfor
+ return lines
+endfunction
+
+function! s:Choose(segment, state)
+ let a:segment.state = a:state
+ if a:state == 'left'
+ let a:segment.result = copy(a:segment.left)
+ elseif a:state == 'right'
+ let a:segment.result = copy(a:segment.right)
+ elseif a:state == 'both'
+ let a:segment.result = a:segment.left + a:segment.right
+ else
+ let a:segment.result = []
+ endif
+endfunction
+
+" ---------------------------------------------------------------- open
+
+" Resolve a:file, relative to the project. The ones a side deleted are asked
+" about instead, since there is nothing to merge line by line
+function! project#git_merge#Open(file)
+ let state = project#git_merge#GetState()
+ let yours = s:ReadStage(a:file, get(state, 'yours', 2))
+ let theirs = s:ReadStage(a:file, get(state, 'theirs', 3))
+ if yours is v:null || theirs is v:null
+ call s:ResolveDeleted(a:file, yours, theirs)
+ return
+ endif
+ let base = s:ReadStage(a:file, 1)
+
+ let [left_title, right_title] = s:GetTitles(state)
+ let s:merge = {
+ \'file': a:file,
+ \'segments': s:BuildSegments(yours, base is v:null ? [] : base, theirs),
+ \'left_title': left_title,
+ \'right_title': right_title,
+ \}
+ call s:OpenWindows()
+ call s:Render()
+ call s:JumpToConflict(1, 1)
+ if empty(s:Conflicts())
+ call project#Info('No conflicting lines left, press '.s:GetMergeKey('finish').' to accept it')
+ endif
+endfunction
+
+function! s:ResolveDeleted(file, yours, theirs)
+ if a:yours is v:null && a:theirs is v:null
+ let question = 'Both sides deleted '.a:file.'. d delete / k keep? '
+ elseif a:yours is v:null
+ let question = 'You deleted '.a:file.', they changed it. d delete / k keep theirs? '
+ else
+ let question = 'They deleted '.a:file.', you changed it. d delete / k keep yours? '
+ endif
+
+ redraw
+ echo question
+ let answer = nr2char(getchar())
+ redraw
+ let file = shellescape(a:file)
+ if answer ==# 'd'
+ call project#RunShellCmd('git rm -q -- '.file)
+ elseif answer ==# 'k' && !(a:yours is v:null && a:theirs is v:null)
+ let lines = a:yours is v:null ? a:theirs : a:yours
+ call writefile(lines, $vim_project.'/'.a:file)
+ call project#RunShellCmd('git add -- '.file)
+ else
+ return
+ endif
+ if !v:shell_error
+ call s:OnFileResolved()
+ endif
+endfunction
+
+function! s:OpenWindows()
+ if bufwinnr(s:result_buffer) != -1
+ execute bufwinnr(s:result_buffer).'wincmd w'
+ return
+ endif
+
+ tabnew
+ call s:SetupPane(s:left_buffer)
+ rightbelow vnew
+ call s:SetupPane(s:result_buffer)
+ rightbelow vnew
+ call s:SetupPane(s:right_buffer)
+ call s:OpenHelp()
+ wincmd =
+ execute bufwinnr(s:result_buffer).'wincmd w'
+endfunction
+
+" The keys at the bottom, always in sight
+function! s:OpenHelp()
+ let mappings = project#config#Get('git_merge_mappings')
+ let rows = [
+ \[['take_yours', 'take yours'], ['take_theirs', 'take theirs'],
+ \ ['take_both', 'take both'], ['edit', 'edit by hand'], ['undo', 'undo']],
+ \[['next', 'next conflict'], ['previous', 'prev conflict'],
+ \ ['all_yours', 'all yours'], ['all_theirs', 'all theirs'],
+ \ ['finish', 'accept, git add'], ['quit', 'discard choices']],
+ \]
+ " Each key is highlighted by its position, labels may hold any word
+ let lines = []
+ let positions = []
+ for row in rows
+ let line = ' '
+ for [name, label] in row
+ let key = s:GetHelpKey(mappings, name)
+ if empty(key)
+ continue
+ endif
+ call add(positions, [len(lines) + 1, len(line) + 1, len(key)])
+ let line .= printf('%-3s %-16s ', key, label)
+ endfor
+ call add(lines, substitute(line, '\s\+$', '', ''))
+ endfor
+
+ botright 2new
+ silent execute 'file' s:help_buffer
+ setlocal buftype=nofile bufhidden=wipe noswapfile nobuflisted
+ setlocal nowrap nonumber nocursorline signcolumn=no winfixheight
+ call setline(1, lines)
+ setlocal nomodifiable
+ setlocal statusline=\ Keys
+ hi default link ProjectMergeKey Special
+ call matchaddpos('ProjectMergeKey', positions, 10)
+endfunction
+
+function! s:SetupPane(name)
+ silent execute 'file' a:name
+ setlocal buftype=nofile bufhidden=wipe noswapfile nobuflisted
+ setlocal nomodifiable nowrap nonumber nofoldenable
+ setlocal scrollbind cursorbind cursorline signcolumn=yes
+ silent! execute 'doautocmd filetypedetect BufRead' fnameescape(s:merge.file)
+
+ let mappings = project#config#Get('git_merge_mappings')
+ call s:AddMapping(mappings.take_yours, 'Take("left")')
+ call s:AddMapping(mappings.take_theirs, 'Take("right")')
+ call s:AddMapping(mappings.take_both, 'Take("both")')
+ call s:AddMapping(mappings.undo, 'Take("")')
+ call s:AddMapping(mappings.edit, 'EditConflict()')
+ call s:AddMapping(mappings.next, 'JumpToConflict(1, 0)')
+ call s:AddMapping(mappings.previous, 'JumpToConflict(-1, 0)')
+ call s:AddMapping(mappings.all_yours, 'TakeAll("left")')
+ call s:AddMapping(mappings.all_theirs, 'TakeAll("right")')
+ call s:AddMapping(mappings.finish, 'Finish()')
+ call s:AddMapping(mappings.quit, 'Quit()')
+
+ " The conflict number follows the cursor
+ autocmd CursorMoved call s:UpdateStatusLines()
+endfunction
+
+function! s:IsMapped(key)
+ return !empty(a:key) && a:key !=? ''
+endfunction
+
+" Key of a:name in the merge window, '' when it has none
+function! s:GetMergeKey(name)
+ return s:GetHelpKey(project#config#Get('git_merge_mappings'), a:name)
+endfunction
+
+function! s:GetHelpKey(mappings, name)
+ let key = get(a:mappings, a:name, '')
+ return s:IsMapped(key) ? key : ''
+endfunction
+
+function! s:AddMapping(key, call)
+ if !s:IsMapped(a:key)
+ return
+ endif
+ let key = a:key == '<' ? '' : a:key
+ execute 'nnoremap '.key.' :call '.a:call.''
+endfunction
+
+" ---------------------------------------------------------------- render
+
+function! s:DefineHighlights()
+ hi default ProjectMergeUnresolved ctermbg=52 guibg=#5f0000
+ hi default ProjectMergeResolved ctermbg=22 guibg=#1f3f1f
+ hi default ProjectMergeManual ctermbg=17 guibg=#1f2f4f
+ hi default ProjectMergePending ctermbg=58 guibg=#4f4000
+ hi default link ProjectMergeUnused Comment
+ hi default link ProjectMergeFiller NonText
+
+ let utf8 = &encoding ==# 'utf-8'
+ let signs = {
+ \'unresolved': ['??', 'ProjectMergeUnresolved'],
+ \'resolved': [utf8 ? '✓' : 'ok', 'ProjectMergeResolved'],
+ \'manual': [utf8 ? '✎' : '~~', 'ProjectMergeManual'],
+ \'pending_left': ['<', 'ProjectMergePending'],
+ \'pending_right': ['>', 'ProjectMergePending'],
+ \'used': [utf8 ? '✓' : 'ok', 'ProjectMergeResolved'],
+ \'unused': ['--', 'ProjectMergeUnused'],
+ \'filler': [' ', 'ProjectMergeFiller'],
+ \}
+ for [name, value] in items(signs)
+ call sign_define('ProjectMerge_'.name, {'text': value[0], 'linehl': value[1], 'texthl': value[1]})
+ endfor
+endfunction
+
+" Sign names of a conflict in the left, middle and right panes
+function! s:GetConflictSigns(segment)
+ let state = a:segment.state
+ if empty(state)
+ return ['pending_left', 'unresolved', 'pending_right']
+ elseif state == 'manual'
+ return ['unused', 'manual', 'unused']
+ endif
+ let left = state == 'left' || state == 'both' ? 'used' : 'unused'
+ let right = state == 'right' || state == 'both' ? 'used' : 'unused'
+ return [left, 'resolved', right]
+endfunction
+
+function! s:Render()
+ call s:DefineHighlights()
+ let panes = [[], [], []]
+ let signs = [[], [], []]
+ let s:layout = []
+
+ for segment in s:merge.segments
+ let start = len(panes[0]) + 1
+ if segment.type == 'text'
+ for pane in panes
+ call extend(pane, segment.lines)
+ endfor
+ call add(s:layout, {'start': start, 'height': len(segment.lines)})
+ continue
+ endif
+
+ let result = empty(segment.state) ? [s:unresolved_text] : segment.result
+ let contents = [segment.left, result, segment.right]
+ let height = max([1, len(segment.left), len(result), len(segment.right)])
+ let names = s:GetConflictSigns(segment)
+ for idx in range(3)
+ let lines = contents[idx]
+ call extend(panes[idx], lines + repeat([''], height - len(lines)))
+ for offset in range(height)
+ let name = offset < len(lines) ? names[idx] : 'filler'
+ call add(signs[idx], [start + offset, name])
+ endfor
+ endfor
+ call add(s:layout, {'start': start, 'height': height})
+ endfor
+
+ let buffers = [s:left_buffer, s:result_buffer, s:right_buffer]
+ for idx in range(3)
+ let buf = bufnr(buffers[idx])
+ call setbufvar(buf, '&modifiable', 1)
+ silent call deletebufline(buf, 1, '$')
+ call setbufline(buf, 1, panes[idx])
+ call setbufvar(buf, '&modifiable', 0)
+ call sign_unplace('ProjectMerge', {'buffer': buf})
+ for [lnum, name] in signs[idx]
+ call sign_place(0, 'ProjectMerge', 'ProjectMerge_'.name, buf, {'lnum': lnum})
+ endfor
+ endfor
+ call s:UpdateStatusLines()
+endfunction
+
+function! s:UpdateStatusLines()
+ if empty(s:merge)
+ return
+ endif
+ let total = len(s:Conflicts())
+ let current = s:GetConflictNumber()
+ let unresolved = s:CountUnresolved()
+ " Shorten the file name first when the pane is narrow
+ let status = printf(' %s/%d %s %%<%s', current ? current : '-', total,
+ \unresolved ? unresolved.' unresolved' : 'all resolved, '.s:GetMergeKey('finish').' to accept',
+ \s:EscapeStatus(s:merge.file))
+ call s:SetStatusLine(s:left_buffer, ' '.s:EscapeStatus(s:merge.left_title))
+ call s:SetStatusLine(s:result_buffer, status)
+ call s:SetStatusLine(s:right_buffer, ' '.s:EscapeStatus(s:merge.right_title))
+endfunction
+
+function! s:EscapeStatus(text)
+ return substitute(a:text, '%', '%%', 'g')
+endfunction
+
+function! s:SetStatusLine(name, text)
+ let winnr = bufwinnr(a:name)
+ if winnr != -1
+ call setwinvar(winnr, '&statusline', a:text)
+ endif
+endfunction
+
+" ---------------------------------------------------------------- actions
+
+" Index of the segment at a:lnum
+function! s:GetSegmentIndex(lnum)
+ for idx in range(len(s:layout))
+ let item = s:layout[idx]
+ if a:lnum >= item.start && a:lnum < item.start + max([1, item.height])
+ return idx
+ endif
+ endfor
+ return -1
+endfunction
+
+function! s:GetCurrentConflict()
+ let idx = s:GetSegmentIndex(line('.'))
+ if idx == -1 || s:merge.segments[idx].type != 'conflict'
+ return -1
+ endif
+ return idx
+endfunction
+
+" 1-based number of the conflict under the cursor, 0 if none
+function! s:GetConflictNumber()
+ let idx = s:GetCurrentConflict()
+ if idx == -1
+ return 0
+ endif
+ return len(filter(s:merge.segments[: idx], 'v:val.type == "conflict"'))
+endfunction
+
+function! s:CursorTo(idx)
+ call cursor(s:layout[a:idx].start, 1)
+ normal! zz
+ call s:UpdateStatusLines()
+endfunction
+
+" Move to the next (a:direction 1) or previous (-1) conflict. With
+" a:unresolved, only to unresolved ones, starting from the cursor itself
+function! s:JumpToConflict(direction, unresolved)
+ let total = len(s:merge.segments)
+ if !total
+ return 0
+ endif
+ let current = s:GetSegmentIndex(line('.'))
+ let idx = a:unresolved && current != -1 ? current : current + a:direction
+ for _ in range(total)
+ let idx = (idx + total) % total
+ let segment = s:merge.segments[idx]
+ if segment.type == 'conflict' && (!a:unresolved || empty(segment.state))
+ call s:CursorTo(idx)
+ return 1
+ endif
+ let idx += a:direction
+ endfor
+ call s:UpdateStatusLines()
+ return 0
+endfunction
+
+" Apply a:state to the conflict under the cursor, then go to the next
+" unresolved one. Off a conflict, only move to the next one so nothing is
+" changed by accident
+function! s:Take(state)
+ let idx = s:GetCurrentConflict()
+ if idx == -1
+ if s:JumpToConflict(1, 0)
+ call project#Info('Moved to the next conflict, press the key again to apply')
+ endif
+ return
+ endif
+
+ call s:Choose(s:merge.segments[idx], a:state)
+ call s:Render()
+ call s:CursorTo(idx)
+ if !empty(a:state) && !s:JumpToConflict(1, 1)
+ call project#Info('All conflicts resolved, press '.s:GetMergeKey('finish').' to accept')
+ endif
+endfunction
+
+function! s:TakeAll(state)
+ let name = a:state == 'left' ? 'yours' : 'theirs'
+ redraw
+ echo 'Take '.name.' for all conflicts? (y/n) '
+ let answer = nr2char(getchar())
+ redraw
+ if answer !=# 'y'
+ return
+ endif
+ for segment in s:Conflicts()
+ call s:Choose(segment, a:state)
+ endfor
+ call s:Render()
+ call s:UpdateStatusLines()
+endfunction
+
+" Edit the result of the current conflict in a split, :w saves it back
+function! s:EditConflict()
+ let idx = s:GetCurrentConflict()
+ if idx == -1
+ call s:JumpToConflict(1, 0)
+ return
+ endif
+ let segment = s:merge.segments[idx]
+ let lines = empty(segment.state) ? segment.left + segment.right : segment.result
+
+ " Full width, above the keys
+ execute bufwinnr(s:help_buffer).'wincmd w'
+ aboveleft new
+ silent execute 'file' s:edit_buffer
+ setlocal buftype=acwrite bufhidden=wipe noswapfile nobuflisted
+ call setline(1, lines)
+ setlocal nomodified
+ let b:vim_project_merge_segment = idx
+ setlocal statusline=\ Edit\ the\ conflict,\ :w\ to\ use\ it,\ :q!\ to\ cancel
+ autocmd BufWriteCmd call s:SaveEditedConflict()
+ " Back to the result however it is closed: :w, :wq, :x or :q!
+ autocmd BufWipeout call timer_start(0, {-> s:BackToResult()})
+endfunction
+
+function! s:SaveEditedConflict()
+ let idx = b:vim_project_merge_segment
+ let segment = s:merge.segments[idx]
+ let segment.state = 'manual'
+ let segment.result = getline(1, '$')
+ if segment.result == ['']
+ let segment.result = []
+ endif
+ setlocal nomodified
+ " Close it once the write is done. Closing it here would let the q of
+ " :wq or :x close the window below it, the result
+ call timer_start(0, {-> s:CloseEdit(idx)})
+endfunction
+
+function! s:CloseEdit(idx)
+ if bufexists(s:edit_buffer)
+ silent! execute 'bwipeout!' bufnr(s:edit_buffer)
+ endif
+ if !s:BackToResult()
+ return
+ endif
+ call s:Render()
+ call s:CursorTo(a:idx)
+ call s:JumpToConflict(1, 1)
+endfunction
+
+function! s:BackToResult()
+ let winnr = bufwinnr(s:result_buffer)
+ if winnr == -1 || empty(s:merge)
+ return 0
+ endif
+ execute winnr.'wincmd w'
+ return 1
+endfunction
+
+" Write the result, git add it, then go back to the local changes
+function! s:Finish()
+ let unresolved = s:CountUnresolved()
+ if unresolved
+ call s:JumpToConflict(1, 1)
+ let quit = s:GetMergeKey('quit')
+ call project#Warn(unresolved.' conflicts left unresolved'
+ \.(s:IsMapped(quit) ? ', '.quit.' to discard the choices' : ''))
+ return
+ endif
+
+ let path = $vim_project.'/'.s:merge.file
+ call writefile(s:GetResultLines(), path)
+ call project#RunShellCmd('git add -- '.shellescape(s:merge.file))
+ if v:shell_error
+ return
+ endif
+ let bufnr = bufnr(path)
+ if bufnr != -1
+ silent! execute 'checktime' bufnr
+ endif
+
+ call s:CloseWindows()
+ call s:OnFileResolved()
+endfunction
+
+function! s:Quit()
+ let touched = len(filter(s:Conflicts(), '!empty(v:val.state)'))
+ if touched
+ redraw
+ echo 'Discard the choices of '.touched.' conflicts? (y/n) '
+ let answer = nr2char(getchar())
+ redraw
+ if answer !=# 'y'
+ return
+ endif
+ endif
+ call s:CloseWindows()
+endfunction
+
+function! s:CloseWindows()
+ for name in [s:edit_buffer, s:help_buffer, s:left_buffer, s:result_buffer, s:right_buffer]
+ if bufexists(name)
+ silent! execute 'bwipeout!' bufnr(name)
+ endif
+ endfor
+ let s:merge = {}
+ let s:layout = []
+endfunction
+
+" Back to the local changes. Once no file is left, offer to finish what git
+" is in the middle of
+function! s:OnFileResolved()
+ call project#git#Status()
+ if !empty(project#git_merge#GetUnmergedFiles())
+ call project#Info('Resolved. Press '.s:GetKey('open_changelist_or_file').' on the next conflicting file')
+ return
+ endif
+
+ let state = project#git_merge#GetState()
+ if empty(state)
+ call project#Info('All conflicts resolved')
+ return
+ endif
+ redraw
+ echo 'All conflicts resolved. Continue the '.state.name.'? (y/n) '
+ let answer = nr2char(getchar())
+ redraw
+ if answer ==# 'y'
+ call project#git_merge#Continue(state)
+ endif
+endfunction
+
+function! project#git_merge#Continue(state)
+ let output = project#RunShellCmd(a:state.continue)
+ if !v:shell_error
+ call project#git#Status()
+ if !empty(project#git_merge#GetUnmergedFiles())
+ " A rebase may stop at the next commit with new conflicts
+ call project#Warn('New conflicts, press '.s:GetKey('open_changelist_or_file').' on a file to resolve it')
+ else
+ call project#Info('Finished the '.a:state.name)
+ endif
+ endif
+endfunction
+
+" Abort the merge, rebase, ... once confirmed
+function! project#git_merge#Abort()
+ let state = project#git_merge#GetState()
+ if empty(state)
+ call project#Info('No merge, rebase, cherry-pick or revert in progress')
+ return
+ endif
+ redraw
+ echo 'Abort the '.state.name.' and go back to before it? (y/n) '
+ let answer = nr2char(getchar())
+ redraw
+ if answer !=# 'y'
+ return
+ endif
+ call project#RunShellCmd(state.abort)
+ if !v:shell_error
+ call project#git#Status()
+ call project#Info('Aborted the '.state.name)
+ endif
+endfunction
+
+" After a failed pull or merge: show the conflicting files if there are any.
+" Return whether there were
+function! project#git_merge#ShowConflicts()
+ let files = project#git_merge#GetUnmergedFiles()
+ if empty(files)
+ return 0
+ endif
+ call project#git#Status()
+ call search('^\S*\s*Unmerged', 'w')
+ redraw
+ call project#Warn('Merge conflicts in '.len(files).' files. Press '
+ \.s:GetKey('open_changelist_or_file').' on a file to resolve it, '
+ \.s:GetKey('abort_merge').' to abort')
+ return 1
+endfunction
+
+" Key of a:name in the local changes, as it reads in a message
+function! s:GetKey(name)
+ let key = get(project#config#Get('git_local_changes_mappings'), a:name, '')
+ return key == "\" ? 'Enter' : key
+endfunction
diff --git a/doc/vim-project.txt b/doc/vim-project.txt
index 5e35bc6..dd2e5bd 100644
--- a/doc/vim-project.txt
+++ b/doc/vim-project.txt
@@ -231,7 +231,9 @@ Go back to a branch with |:ProjectGitBranch|.
|:ProjectGitBranch| lists branches. switches to the selected one, or
creates a local branch for a remote one. The "New Branch" item asks for a
name. merges the selected branch into the current one, after
-asking.
+asking. Opening it runs "git fetch --all --prune" in the background, and
+the list is refreshed when it is done, so new remote branches show up
+without leaving Vim. Turn it off with "git_branch_fetch".
|:ProjectGitTag| lists tags. checks out the selected tag. The "New Tag"
item runs "git tag" with what you type.
@@ -254,6 +256,35 @@ changelists. Default keys ("git_local_changes_mappings"):
u git pull
p git push
P git pull, then git push
+ A abort the merge, rebase, cherry-pick or revert in progress
+
+Merge conflicts~
+When a pull or a branch merge stops on conflicts, the local changes open on
+the Unmerged changelist. ("open_changelist_or_file") on a conflicting
+file opens the merge window
+in a new tab, like the Merge dialog of IntelliJ IDEA:
+
+ yours (left) | result (middle) | theirs (right)
+
+Panes are titled with the branch and commit they come from; in a rebase
+yours is the commit being rebased. Conflicts line up across the panes and
+are colored by state: unresolved, taken from a side, or edited by hand.
+All panes are read-only, so a choice can always be changed. Keys work on
+the conflict under the cursor, and are listed at the bottom of the window
+("git_merge_mappings"):
+
+ < take yours > take theirs
+ = take both e edit it by hand, :w to use it
+ u back to unresolved ]c [c next or previous conflict
+ L R take yours or theirs for all conflicts
+ q accept: write the file, git add it and close. Refused
+ while conflicts are unresolved. Like the commit message,
+ closing accepts
+ Q discard: close, the file stays as it was
+
+A file deleted on one side asks to keep or delete it instead. Once no
+conflicting file is left, it offers to commit the merge, or to continue the
+rebase, cherry-pick or revert.
Pull and push run in the background; the result is shown when done. git
cannot ask for credentials there, so use a credential helper or ssh-agent.
@@ -288,6 +319,9 @@ check_branch_when_use_session 0
Keep one session per git branch, and switch sessions when the branch
changes. Needs |job| support and the "tail" command.
+git_branch_fetch 1
+ Fetch all remotes in the background when |:ProjectGitBranch| opens.
+
open_root_when_use_session 0
Open the project root even when a session is loaded.
@@ -363,6 +397,7 @@ README.md for every default:
git_diff_mappings diff buffer
git_changes_mappings changes buffer
git_local_changes_mappings |:ProjectGitStatus|
+ git_merge_mappings merge conflicts window
*vim-project-files*
Config files are kept in "config_home": >
diff --git a/test/cases/git_branch_test.vim b/test/cases/git_branch_test.vim
index c7e9101..3e7911d 100644
--- a/test/cases/git_branch_test.vim
+++ b/test/cases/git_branch_test.vim
@@ -67,3 +67,28 @@ function! Test_merge_branch_with_conflict_leaves_it_to_resolve()
call s:CloseRepo(repo)
endtry
endfunction
+
+function! Test_branch_list_fetches_in_background()
+ let repo = s:OpenRepo('branch fetch', 'feature.txt')
+ let remote = fnamemodify(repo, ':h').'/remote.git'
+ let other = fnamemodify(repo, ':h').'/other'
+ try
+ call system('git init -q --bare '.shellescape(remote))
+ call s:Git(repo, 'remote add origin '.shellescape(remote))
+ call s:Git(repo, 'push -q origin HEAD')
+ " Someone else pushes a new branch
+ call system('git clone -q '.shellescape(remote).' '.shellescape(other))
+ call s:Git(other, 'push -q origin HEAD:refs/heads/from-other')
+ call assert_notmatch('from-other', s:Git(repo, 'branch -r'))
+
+ call Helper_Call('autoload/project/git_branch.vim', 'StartFetch', [])
+ let tries = 0
+ while Helper_ScriptVar('autoload/project/git_branch.vim', 'fetch_job') isnot v:null && tries < 200
+ sleep 20m
+ let tries += 1
+ endwhile
+ call assert_match('origin/from-other', s:Git(repo, 'branch -r'))
+ finally
+ call s:CloseRepo(repo)
+ endtry
+endfunction
diff --git a/test/cases/git_merge_test.vim b/test/cases/git_merge_test.vim
new file mode 100644
index 0000000..41fb4c5
--- /dev/null
+++ b/test/cases/git_merge_test.vim
@@ -0,0 +1,232 @@
+" Tests of the merge window: resolving conflicts in yours | result | theirs
+let s:file = 'autoload/project/git_merge.vim'
+
+function! s:Call(name, ...)
+ return Helper_Call(s:file, a:name, a:000)
+endfunction
+
+function! s:Git(repo, args)
+ return system('git -C '.shellescape(a:repo).' '.a:args)
+endfunction
+
+" A project in the middle of merging 'feature', which conflicts with the
+" current branch on lines 2 and 4 of file.txt
+function! s:OpenConflict(name)
+ let base = Helper_TmpDir(a:name)
+ let repo = base.'/proj'
+ call mkdir(repo, 'p')
+ call writefile(['a', 'b', 'c', 'd', 'e'], repo.'/file.txt')
+ call s:Git(repo, 'init -q')
+ call s:Git(repo, 'config user.name t')
+ call s:Git(repo, 'config user.email t@t')
+ call s:Git(repo, 'add .')
+ call s:Git(repo, 'commit -q -m init')
+ call s:Git(repo, 'checkout -q -b feature')
+ call writefile(['a', 'B theirs', 'c', 'D theirs', 'e'], repo.'/file.txt')
+ call s:Git(repo, 'commit -q -am feature')
+ call s:Git(repo, 'checkout -q -')
+ call writefile(['a', 'B yours', 'c', 'D yours', 'e'], repo.'/file.txt')
+ call s:Git(repo, 'commit -q -am mine')
+ call s:Git(repo, 'merge feature')
+
+ let project = {'name': 'proj', 'path': base, 'fullpath': repo, 'note': '', 'option': {}}
+ call Helper_Call('autoload/project.vim', 'InitProjectConfig', [project])
+ call project#OpenProject(project)
+ return repo
+endfunction
+
+function! s:Close(repo)
+ call s:Git(a:repo, 'merge --abort')
+ silent! call s:Call('CloseWindows')
+ silent! execute 'bwipeout!' bufnr('local_changes')
+ call project#QuitProject()
+ silent! tabonly!
+ silent! %bwipeout!
+ " The headless screen is small, four windows squeeze the command line
+ set cmdheight=1
+endfunction
+
+function! s:Lines(name)
+ return getbufline(bufnr(a:name), 1, '$')
+endfunction
+
+function! Test_ParseMergeOutput()
+ let segments = s:Call('ParseMergeOutput', ['a', '<<<<<<< yours', 'x',
+ \'||||||| base', 'o', '=======', 'y', 'z', '>>>>>>> theirs', 'b'])
+ call assert_equal(3, len(segments))
+ call assert_equal(['a'], segments[0].lines)
+ call assert_equal(['x'], segments[1].left)
+ call assert_equal(['o'], segments[1].base)
+ call assert_equal(['y', 'z'], segments[1].right)
+ call assert_equal(['b'], segments[2].lines)
+endfunction
+
+function! Test_merge_state_and_unmerged_status()
+ let repo = s:OpenConflict('merge state')
+ try
+ call assert_equal('merge', project#git_merge#GetState().name)
+ call assert_equal(['file.txt'], project#git_merge#GetUnmergedFiles())
+ call project#git#Status()
+ call assert_equal(['U file.txt'], Helper_ScriptVar('autoload/project/git.vim', 'unmerged_files'))
+ finally
+ call s:Close(repo)
+ endtry
+endfunction
+
+function! Test_resolve_conflicts_in_merge_window()
+ let repo = s:OpenConflict('merge window')
+ try
+ call project#git_merge#Open('file.txt')
+ call assert_equal('vim_project_merge_result', bufname('%'))
+ " Panes line up, the cursor is on the first conflict
+ call assert_equal(['a', 'B yours', 'c', 'D yours', 'e'], s:Lines('vim_project_merge_yours'))
+ call assert_equal(['a', 'B theirs', 'c', 'D theirs', 'e'], s:Lines('vim_project_merge_theirs'))
+ call assert_equal(2, line('.'))
+ " The keys stay in sight at the bottom
+ call Helper_AssertContains(join(s:Lines('vim_project_merge_keys')), '< take yours')
+ call Helper_AssertContains(join(s:Lines('vim_project_merge_keys')), 'q accept, git add')
+ call assert_equal('(unresolved)', getline(2))
+
+ " The conflict number follows the cursor
+ let winnr = bufwinnr('vim_project_merge_result')
+ call Helper_AssertContains(getwinvar(winnr, '&statusline'), ' 1/2 ')
+ call cursor(4, 1)
+ doautocmd CursorMoved
+ call Helper_AssertContains(getwinvar(winnr, '&statusline'), ' 2/2 ')
+ call cursor(3, 1)
+ doautocmd CursorMoved
+ call Helper_AssertContains(getwinvar(winnr, '&statusline'), ' -/2 ')
+ call cursor(2, 1)
+
+ " Finishing with conflicts left keeps the window
+ normal q
+ call assert_equal('vim_project_merge_result', bufname('%'))
+
+ " Take yours, then the cursor moves to the next conflict: take theirs
+ normal <
+ call assert_equal('B yours', getline(2))
+ call assert_equal(4, line('.'))
+ normal >
+ call assert_equal('D theirs', getline(4))
+
+ " Undo it, and take both instead: the conflict grows, panes still align
+ normal u
+ call assert_equal('(unresolved)', getline(4))
+ normal =
+ call assert_equal(['a', 'B yours', 'c', 'D yours', 'D theirs', 'e'], s:Lines('vim_project_merge_result'))
+ call assert_equal(['a', 'B yours', 'c', 'D yours', '', 'e'], s:Lines('vim_project_merge_yours'))
+
+ " Finish: written, added, back to the local changes. Don't continue yet
+ call feedkeys('qn', 'xt')
+ call assert_equal(['a', 'B yours', 'c', 'D yours', 'D theirs', 'e'], readfile(repo.'/file.txt'))
+ call assert_equal(-1, bufwinnr('vim_project_merge_result'))
+ call assert_equal([], project#git_merge#GetUnmergedFiles())
+ call assert_equal('merge', project#git_merge#GetState().name)
+ finally
+ call s:Close(repo)
+ endtry
+endfunction
+
+function! Test_finish_continues_the_merge()
+ let repo = s:OpenConflict('merge continue')
+ try
+ call project#git_merge#Open('file.txt')
+ " :normal ends its keys with , feed the answers with them instead
+ call feedkeys('Ry', 'xt')
+ call assert_equal(['a', 'B theirs', 'c', 'D theirs', 'e'], s:Call('GetResultLines'))
+ call feedkeys('qy', 'xt')
+ call assert_equal({}, project#git_merge#GetState())
+ call assert_match('Merge', s:Git(repo, 'log -1 --format=%s'))
+ finally
+ call s:Close(repo)
+ endtry
+endfunction
+
+function! Test_edit_conflict_by_hand()
+ let repo = s:OpenConflict('merge edit')
+ try
+ call project#git_merge#Open('file.txt')
+ normal e
+ call assert_equal('vim_project_merge_edit', bufname('%'))
+ call setline(1, ['B mixed', 'B more'])
+ silent write
+ " The edit window closes once the write is done
+ sleep 50m
+ call assert_equal('vim_project_merge_result', bufname('%'))
+ call assert_equal(['B mixed', 'B more'], getline(2, 3))
+ call assert_equal('manual', s:Call('Conflicts')[0].state)
+
+ " :wq and :q! close only the edit window, never the result
+ for cmd in ['wq', 'x', 'q!']
+ call cursor(5, 1)
+ normal e
+ call assert_equal('vim_project_merge_edit', bufname('%'))
+ silent execute cmd
+ sleep 50m
+ call assert_equal('vim_project_merge_result', bufname('%'), cmd)
+ call assert_notequal(-1, bufwinnr('vim_project_merge_yours'), cmd)
+ call assert_notequal(-1, bufwinnr('vim_project_merge_theirs'), cmd)
+ endfor
+ finally
+ call s:Close(repo)
+ endtry
+endfunction
+
+function! Test_q_accepts_Q_discards()
+ let repo = s:OpenConflict('merge quit')
+ try
+ call project#git_merge#Open('file.txt')
+ " q is refused while conflicts are unresolved
+ normal q
+ call assert_equal('vim_project_merge_result', bufname('%'))
+ " Q discards, once confirmed when choices were made
+ normal <
+ call feedkeys('Qn', 'xt')
+ call assert_equal('vim_project_merge_result', bufname('%'))
+ call feedkeys('Qy', 'xt')
+ call assert_equal(-1, bufwinnr('vim_project_merge_result'))
+ call assert_equal(['file.txt'], project#git_merge#GetUnmergedFiles())
+
+ " q accepts once all are resolved
+ call project#git_merge#Open('file.txt')
+ " Then don't continue the merge yet
+ call feedkeys('Ryqn', 'xt')
+ call assert_equal(-1, bufwinnr('vim_project_merge_result'))
+ call assert_equal(['a', 'B theirs', 'c', 'D theirs', 'e'], readfile(repo.'/file.txt'))
+ call assert_equal([], project#git_merge#GetUnmergedFiles())
+ finally
+ call s:Close(repo)
+ endtry
+endfunction
+
+function! Test_messages_name_the_configured_keys()
+ let repo = s:OpenConflict('merge keys')
+ let mappings = project#config#Get('git_merge_mappings')
+ let saved = copy(mappings)
+ let mappings.finish = 'X'
+ let mappings.quit = 'Z'
+ try
+ call project#git_merge#Open('file.txt')
+ call Helper_AssertContains(join(s:Lines('vim_project_merge_keys')), 'X accept, git add')
+ normal X
+ call Helper_AssertContains(execute('messages'), 'Z to discard the choices')
+ call feedkeys('Ry', 'xt')
+ let winnr = bufwinnr('vim_project_merge_result')
+ call Helper_AssertContains(getwinvar(winnr, '&statusline'), 'X to accept')
+ finally
+ call extend(mappings, saved)
+ call s:Close(repo)
+ endtry
+endfunction
+
+function! Test_abort_merge()
+ let repo = s:OpenConflict('merge abort')
+ try
+ call feedkeys('y', 't')
+ call project#git_merge#Abort()
+ call assert_equal({}, project#git_merge#GetState())
+ call assert_equal(['a', 'B yours', 'c', 'D yours', 'e'], readfile(repo.'/file.txt'))
+ finally
+ call s:Close(repo)
+ endtry
+endfunction
diff --git a/test/demo_merge.sh b/test/demo_merge.sh
new file mode 100755
index 0000000..d722616
--- /dev/null
+++ b/test/demo_merge.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+# Make a repository that stops on merge conflicts, to try the merge window.
+#
+# test/demo_merge.sh # merge with two conflicts in f.txt
+# test/demo_merge.sh rebase # the same, stopped in a rebase
+# test/demo_merge.sh delete # they deleted f.txt, you changed it
+# test/demo_merge.sh merge DIR # in DIR instead of /tmp/merge-demo
+#
+# Run it again to start over: DIR is removed first.
+set -eu
+
+mode="${1:-merge}"
+# Not $TMPDIR: on macOS it is a long per-user path under /var/folders
+dir="${2:-/tmp/merge-demo}"
+plugin="$(cd "$(dirname "$0")/.." && pwd)"
+
+case "$mode" in
+ merge|rebase|delete) ;;
+ *)
+ echo "Unknown mode: $mode (merge, rebase or delete)" >&2
+ exit 1
+ ;;
+esac
+
+echo "==> Making $dir"
+rm -rf "$dir"
+mkdir -p "$dir"
+cd "$dir"
+git init -q
+git config user.name demo
+git config user.email demo@example.com
+git checkout -q -b main
+
+printf 'a\nb\nc\nd\ne\n' > f.txt
+git add f.txt
+git commit -q -m init
+
+git checkout -q -b feature
+if [ "$mode" = delete ]; then
+ git rm -q f.txt
+ git commit -q -m 'feature: delete f.txt'
+else
+ printf 'a\nB theirs\nc\nD theirs\ne\n' > f.txt
+ git commit -q -am 'feature: change b and d'
+fi
+
+git checkout -q main
+printf 'a\nB yours\nc\nD yours\ne\n' > f.txt
+git commit -q -am 'main: change b and d'
+
+cmd=merge
+[ "$mode" = rebase ] && cmd=rebase
+echo "==> Running git $cmd feature, it stops on conflicts:"
+if [ "$cmd" = rebase ]; then
+ git rebase feature >/dev/null 2>&1 || true
+else
+ git merge feature >/dev/null 2>&1 || true
+fi
+git status --short | sed 's/^/ /'
+
+cat < Now, in Vim with this plugin (the dev-merge branch) loaded:
+
+ :Project $dir
+ :ProjectGitStatus
+
+ :ProjectRemove $(basename "$dir") forgets the project when you are done.
+
+ Or straight into the merge window, without a project:
+
+ cd $dir
+ vim -c 'set rtp^=$plugin' -c 'let \$vim_project = getcwd()' -c "call project#git_merge#Open('f.txt')"
+
+==> In the local changes, expand Unmerged and open f.txt (Enter by default,
+ "open_changelist_or_file"). In the merge window:
+
+ < take yours > take theirs = both e edit by hand u undo
+ ]c / [c move between conflicts q accept, git add Q discard choices
+
+ A in the local changes aborts the $cmd. Run this script again to start over.
+EOF
diff --git a/test/runner.vim b/test/runner.vim
index 9f04992..7f6a494 100644
--- a/test/runner.vim
+++ b/test/runner.vim
@@ -73,6 +73,7 @@ runtime plugin/project.vim
runtime autoload/project/find_in_files.vim
runtime autoload/project/list_projects.vim
runtime autoload/project/git.vim
+runtime autoload/project/git_merge.vim
execute 'source '.fnameescape(s:test_dir.'/helpers.vim')
" $VIM_PROJECT_TEST_CASES picks another folder, e.g. 'perf'