From eda7209fe82c9876c4eee6e2b9d808897aa5e02a Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Mon, 26 Oct 2026 11:06:06 +0000 Subject: [PATCH 01/16] Resolve merge conflicts in a three-pane merge window A pull or branch merge that stops on conflicts now opens the local changes on the Unmerged changelist instead of only printing git's output. Enter on a conflicting file opens yours | result | theirs in a new tab, like IntelliJ IDEA's Merge dialog: conflicts line up and are colored by state, every choice applies to the conflict under the cursor and can be changed or undone, and the file is written and added only once all are resolved. After the last file it offers to commit the merge or continue the rebase, cherry-pick or revert; A aborts it. All unmerged statuses (AA, DU, UD, ...) are listed as conflicts now, not only UU, and a file deleted on one side asks to keep or delete it. Claude-Session: https://claude.ai/code/session_01BwofrMkZ4QpGsCYYSgncrd --- README.md | 3 + autoload/project/config.vim | 15 + autoload/project/git.vim | 23 +- autoload/project/git_branch.vim | 5 +- autoload/project/git_merge.vim | 642 ++++++++++++++++++++++++++++++++ doc/vim-project.txt | 27 ++ test/cases/git_merge_test.vim | 157 ++++++++ test/runner.vim | 1 + 8 files changed, 866 insertions(+), 7 deletions(-) create mode 100644 autoload/project/git_merge.vim create mode 100644 test/cases/git_merge_test.vim diff --git a/README.md b/README.md index 25b4bde..b267896 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, w to write and `git add` it once all are resolved. After the last file it offers to commit the merge or continue the rebase. ### Git log, file history, branches and tags diff --git a/autoload/project/config.vim b/autoload/project/config.vim index edd1812..f172f9c 100644 --- a/autoload/project/config.vim +++ b/autoload/project/config.vim @@ -105,6 +105,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': 'w', + \'quit': 'q', \} let s:default.file_open_types = { \'': 'edit', @@ -126,6 +140,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..430f31a 100644 --- a/autoload/project/git_branch.vim +++ b/autoload/project/git_branch.vim @@ -111,8 +111,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..79fb88f --- /dev/null +++ b/autoload/project/git_merge.vim @@ -0,0 +1,642 @@ +" 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:keys_hint = '< yours > theirs = both e edit u undo ]c [c move L R all w finish q quit' + +" 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 w to mark it resolved') + else + call s:ShowKeys() + 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) + wincmd = + execute bufwinnr(s:result_buffer).'wincmd w' +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()') +endfunction + +function! s:AddMapping(key, call) + if a:key ==? '' || empty(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() + let total = len(s:Conflicts()) + let current = s:GetConflictNumber() + let unresolved = s:CountUnresolved() + let status = printf(' Result %s %s/%d %s', s:merge.file, + \current ? current : '-', total, + \unresolved ? unresolved.' unresolved' : 'all resolved, w to finish') + call s:SetStatusLine(s:left_buffer, ' '.s:EscapeStatus(s:merge.left_title)) + call s:SetStatusLine(s:result_buffer, s:EscapeStatus(status)) + call s:SetStatusLine(s:right_buffer, ' '.s:EscapeStatus(s:merge.right_title)) +endfunction + +function! s:ShowKeys() + redraw + call project#InfoEcho(s:keys_hint) +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 w to save and mark it resolved') + else + call s:ShowKeys() + 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 + + execute bufwinnr(s:result_buffer).'wincmd w' + botright 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() +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 + bwipeout + execute bufwinnr(s:result_buffer).'wincmd w' + call s:Render() + call s:CursorTo(idx) + call s:JumpToConflict(1, 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) + call project#Warn(unresolved.' conflicts left unresolved') + 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: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 Enter 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 Enter 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 Enter on a file to resolve it, A to abort') + return 1 +endfunction diff --git a/doc/vim-project.txt b/doc/vim-project.txt index 5e35bc6..cb1abb1 100644 --- a/doc/vim-project.txt +++ b/doc/vim-project.txt @@ -254,6 +254,32 @@ 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. 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 ("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 + w finish: write the file and git add it. Refused while + conflicts are unresolved + q close without writing + +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. @@ -363,6 +389,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_merge_test.vim b/test/cases/git_merge_test.vim new file mode 100644 index 0000000..6a0a1fa --- /dev/null +++ b/test/cases/git_merge_test.vim @@ -0,0 +1,157 @@ +" 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('.')) + call assert_equal('(unresolved)', getline(2)) + + " Finishing with conflicts left keeps the window + normal w + 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('wn', '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('wy', '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 + 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) + finally + 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/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' From c4bd2bb509b9f16847f04b671bedec70c0f7500f Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Mon, 26 Oct 2026 11:21:13 +0000 Subject: [PATCH 02/16] Name the configured keys in the merge conflict messages Claude-Session: https://claude.ai/code/session_01BwofrMkZ4QpGsCYYSgncrd --- autoload/project/git_merge.vim | 14 +++++++++++--- doc/vim-project.txt | 3 ++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/autoload/project/git_merge.vim b/autoload/project/git_merge.vim index 79fb88f..fe0b3b1 100644 --- a/autoload/project/git_merge.vim +++ b/autoload/project/git_merge.vim @@ -575,7 +575,7 @@ endfunction function! s:OnFileResolved() call project#git#Status() if !empty(project#git_merge#GetUnmergedFiles()) - call project#Info('Resolved. Press Enter on the next conflicting file') + call project#Info('Resolved. Press '.s:GetKey('open_changelist_or_file').' on the next conflicting file') return endif @@ -599,7 +599,7 @@ function! project#git_merge#Continue(state) 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 Enter on a file to resolve it') + 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 @@ -637,6 +637,14 @@ function! project#git_merge#ShowConflicts() call project#git#Status() call search('^\S*\s*Unmerged', 'w') redraw - call project#Warn('Merge conflicts in '.len(files).' files. Press Enter on a file to resolve it, A to abort') + 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 cb1abb1..ea14ac9 100644 --- a/doc/vim-project.txt +++ b/doc/vim-project.txt @@ -258,7 +258,8 @@ changelists. Default keys ("git_local_changes_mappings"): Merge conflicts~ When a pull or a branch merge stops on conflicts, the local changes open on -the Unmerged changelist. on a conflicting file opens the merge window +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) From 554d5894c008db51d92972c047d80e5e03cdb481 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Mon, 26 Oct 2026 11:25:37 +0000 Subject: [PATCH 03/16] Mark pending sides with the key that takes them Claude-Session: https://claude.ai/code/session_01BwofrMkZ4QpGsCYYSgncrd --- autoload/project/git_merge.vim | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/autoload/project/git_merge.vim b/autoload/project/git_merge.vim index fe0b3b1..ff9c8df 100644 --- a/autoload/project/git_merge.vim +++ b/autoload/project/git_merge.vim @@ -291,8 +291,8 @@ function! s:DefineHighlights() \'unresolved': ['??', 'ProjectMergeUnresolved'], \'resolved': [utf8 ? '✓' : 'ok', 'ProjectMergeResolved'], \'manual': [utf8 ? '✎' : '~~', 'ProjectMergeManual'], - \'pending_left': ['>>', 'ProjectMergePending'], - \'pending_right': ['<<', 'ProjectMergePending'], + \'pending_left': ['<', 'ProjectMergePending'], + \'pending_right': ['>', 'ProjectMergePending'], \'used': [utf8 ? '✓' : 'ok', 'ProjectMergeResolved'], \'unused': ['--', 'ProjectMergeUnused'], \'filler': [' ', 'ProjectMergeFiller'], From 789733c7c60d5e7426b36c62c41c5c6cdd11b296 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Mon, 26 Oct 2026 11:26:43 +0000 Subject: [PATCH 04/16] Keep the merge window keys in sight at the bottom Claude-Session: https://claude.ai/code/session_01BwofrMkZ4QpGsCYYSgncrd --- autoload/project/git_merge.vim | 53 +++++++++++++++++++++++++--------- doc/vim-project.txt | 3 +- test/cases/git_merge_test.vim | 3 ++ 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/autoload/project/git_merge.vim b/autoload/project/git_merge.vim index ff9c8df..b6efd99 100644 --- a/autoload/project/git_merge.vim +++ b/autoload/project/git_merge.vim @@ -15,7 +15,7 @@ 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:keys_hint = '< yours > theirs = both e edit u undo ]c [c move L R all w finish q quit' +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', @@ -198,8 +198,6 @@ function! project#git_merge#Open(file) call s:JumpToConflict(1, 1) if empty(s:Conflicts()) call project#Info('No conflicting lines left, press w to mark it resolved') - else - call s:ShowKeys() endif endfunction @@ -243,10 +241,45 @@ function! s:OpenWindows() 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', 'previous conflict'], + \ ['all_yours', 'all yours'], ['all_theirs', 'all theirs'], + \ ['finish', 'save and git add'], ['quit', 'quit']], + \] + let lines = [] + for row in rows + let items = [] + for [name, label] in row + let key = get(mappings, name, '') + if !empty(key) && key !=? '' + call add(items, printf('%-3s %-18s', key, label)) + endif + endfor + call add(lines, ' '.join(items, ' ')) + 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 + " A key starts each item: first on the line, or after two spaces + syntax match ProjectMergeKey /\%(^ \| \)\zs\S\+/ + hi default link ProjectMergeKey Special +endfunction + function! s:SetupPane(name) silent execute 'file' a:name setlocal buftype=nofile bufhidden=wipe noswapfile nobuflisted @@ -373,11 +406,6 @@ function! s:UpdateStatusLines() call s:SetStatusLine(s:right_buffer, ' '.s:EscapeStatus(s:merge.right_title)) endfunction -function! s:ShowKeys() - redraw - call project#InfoEcho(s:keys_hint) -endfunction - function! s:EscapeStatus(text) return substitute(a:text, '%', '%%', 'g') endfunction @@ -464,8 +492,6 @@ function! s:Take(state) call s:CursorTo(idx) if !empty(a:state) && !s:JumpToConflict(1, 1) call project#Info('All conflicts resolved, press w to save and mark it resolved') - else - call s:ShowKeys() endif endfunction @@ -495,8 +521,9 @@ function! s:EditConflict() let segment = s:merge.segments[idx] let lines = empty(segment.state) ? segment.left + segment.right : segment.result - execute bufwinnr(s:result_buffer).'wincmd w' - botright new + " 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) @@ -561,7 +588,7 @@ function! s:Quit() endfunction function! s:CloseWindows() - for name in [s:edit_buffer, s:left_buffer, s:result_buffer, s:right_buffer] + 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 diff --git a/doc/vim-project.txt b/doc/vim-project.txt index ea14ac9..0d8bf10 100644 --- a/doc/vim-project.txt +++ b/doc/vim-project.txt @@ -268,7 +268,8 @@ 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 ("git_merge_mappings"): +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 diff --git a/test/cases/git_merge_test.vim b/test/cases/git_merge_test.vim index 6a0a1fa..8afb2bc 100644 --- a/test/cases/git_merge_test.vim +++ b/test/cases/git_merge_test.vim @@ -82,6 +82,9 @@ function! Test_resolve_conflicts_in_merge_window() 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')), 'w save and git add') call assert_equal('(unresolved)', getline(2)) " Finishing with conflicts left keeps the window From 09ddbe5159d85ce295ebb33ef81b00d997507e74 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Mon, 26 Oct 2026 11:33:41 +0000 Subject: [PATCH 05/16] Add test/demo_merge.sh to try the merge window by hand It makes a repository stopped on conflicts in a merge, a rebase or with a deleted file, then prints how to open it and the keys to use. Claude-Session: https://claude.ai/code/session_01BwofrMkZ4QpGsCYYSgncrd --- test/demo_merge.sh | 81 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100755 test/demo_merge.sh diff --git a/test/demo_merge.sh b/test/demo_merge.sh new file mode 100755 index 0000000..50a205a --- /dev/null +++ b/test/demo_merge.sh @@ -0,0 +1,81 @@ +#!/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}" +dir="${2:-${TMPDIR:-/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 w save and git add q quit + + A in the local changes aborts the $cmd. Run this script again to start over. +EOF From c2f24117c4c1ffea3a129d89cbf493fdf5091ab4 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Mon, 26 Oct 2026 13:07:22 +0000 Subject: [PATCH 06/16] Highlight only the keys in the merge help, keep the status readable The help colored the first word of each label too, since labels follow the keys after spaces. Keys are highlighted by position now. The result pane's status line cut its start when narrow; the file name is cut first. Claude-Session: https://claude.ai/code/session_01BwofrMkZ4QpGsCYYSgncrd --- autoload/project/git_merge.vim | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/autoload/project/git_merge.vim b/autoload/project/git_merge.vim index b6efd99..b733f10 100644 --- a/autoload/project/git_merge.vim +++ b/autoload/project/git_merge.vim @@ -256,16 +256,20 @@ function! s:OpenHelp() \ ['all_yours', 'all yours'], ['all_theirs', 'all theirs'], \ ['finish', 'save and git add'], ['quit', 'quit']], \] + " Each key is highlighted by its position, labels may hold any word let lines = [] + let positions = [] for row in rows - let items = [] + let line = ' ' for [name, label] in row let key = get(mappings, name, '') - if !empty(key) && key !=? '' - call add(items, printf('%-3s %-18s', key, label)) + if empty(key) || key ==? '' + continue endif + call add(positions, [len(lines) + 1, len(line) + 1, len(key)]) + let line .= printf('%-3s %-18s ', key, label) endfor - call add(lines, ' '.join(items, ' ')) + call add(lines, substitute(line, '\s\+$', '', '')) endfor botright 2new @@ -275,9 +279,8 @@ function! s:OpenHelp() call setline(1, lines) setlocal nomodifiable setlocal statusline=\ Keys - " A key starts each item: first on the line, or after two spaces - syntax match ProjectMergeKey /\%(^ \| \)\zs\S\+/ hi default link ProjectMergeKey Special + call matchaddpos('ProjectMergeKey', positions, 10) endfunction function! s:SetupPane(name) @@ -398,11 +401,12 @@ function! s:UpdateStatusLines() let total = len(s:Conflicts()) let current = s:GetConflictNumber() let unresolved = s:CountUnresolved() - let status = printf(' Result %s %s/%d %s', s:merge.file, - \current ? current : '-', total, - \unresolved ? unresolved.' unresolved' : 'all resolved, w to finish') + " 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, w to finish', + \s:EscapeStatus(s:merge.file)) call s:SetStatusLine(s:left_buffer, ' '.s:EscapeStatus(s:merge.left_title)) - call s:SetStatusLine(s:result_buffer, s:EscapeStatus(status)) + call s:SetStatusLine(s:result_buffer, status) call s:SetStatusLine(s:right_buffer, ' '.s:EscapeStatus(s:merge.right_title)) endfunction From f9cfa1fe2d881f4d4feb378dc041572716ae06b6 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Tue, 27 Oct 2026 01:06:21 +0000 Subject: [PATCH 07/16] Close only the edit window on :wq in the merge window Saving an edited conflict wiped its buffer during the write, so the q of :wq or :x then closed the result pane below it. The edit window now closes once the write is done, and any way of closing it, :q! too, goes back to the result. Claude-Session: https://claude.ai/code/session_01BwofrMkZ4QpGsCYYSgncrd --- autoload/project/git_merge.vim | 27 ++++++++++++++++++++++++--- test/cases/git_merge_test.vim | 14 ++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/autoload/project/git_merge.vim b/autoload/project/git_merge.vim index b733f10..82e5831 100644 --- a/autoload/project/git_merge.vim +++ b/autoload/project/git_merge.vim @@ -535,6 +535,8 @@ function! s:EditConflict() 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() @@ -546,13 +548,32 @@ function! s:SaveEditedConflict() let segment.result = [] endif setlocal nomodified - bwipeout - execute bufwinnr(s:result_buffer).'wincmd w' + " 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(idx) + 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() diff --git a/test/cases/git_merge_test.vim b/test/cases/git_merge_test.vim index 8afb2bc..e8e2091 100644 --- a/test/cases/git_merge_test.vim +++ b/test/cases/git_merge_test.vim @@ -139,9 +139,23 @@ function! Test_edit_conflict_by_hand() 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 From 387d753483b08db6f043be1f5f2d75cb45d6273c Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Tue, 27 Oct 2026 01:07:42 +0000 Subject: [PATCH 08/16] Make the merge demo in /tmp/merge-demo on macOS too Claude-Session: https://claude.ai/code/session_01BwofrMkZ4QpGsCYYSgncrd --- test/demo_merge.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/demo_merge.sh b/test/demo_merge.sh index 50a205a..123d5d9 100755 --- a/test/demo_merge.sh +++ b/test/demo_merge.sh @@ -10,7 +10,8 @@ set -eu mode="${1:-merge}" -dir="${2:-${TMPDIR:-/tmp}/merge-demo}" +# 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 From 40b5fb755604a0cd992e674e2a57ed590701d852 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Tue, 27 Oct 2026 01:36:14 +0000 Subject: [PATCH 09/16] Name w accept and q discard in the merge window help Claude-Session: https://claude.ai/code/session_01BwofrMkZ4QpGsCYYSgncrd --- README.md | 2 +- autoload/project/git_merge.vim | 6 +++--- doc/vim-project.txt | 4 ++-- test/cases/git_merge_test.vim | 2 +- test/demo_merge.sh | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b267896..9caa7b9 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ let g:vim_project_local_config = { | 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, w to write and `git add` it once all are resolved. After the last file it offers to commit the merge or continue the rebase. +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, w 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 diff --git a/autoload/project/git_merge.vim b/autoload/project/git_merge.vim index 82e5831..dc4ec28 100644 --- a/autoload/project/git_merge.vim +++ b/autoload/project/git_merge.vim @@ -252,9 +252,9 @@ function! s:OpenHelp() let rows = [ \[['take_yours', 'take yours'], ['take_theirs', 'take theirs'], \ ['take_both', 'take both'], ['edit', 'edit by hand'], ['undo', 'undo']], - \[['next', 'next conflict'], ['previous', 'previous conflict'], + \[['next', 'next conflict'], ['previous', 'prev conflict'], \ ['all_yours', 'all yours'], ['all_theirs', 'all theirs'], - \ ['finish', 'save and git add'], ['quit', 'quit']], + \ ['finish', 'accept, git add'], ['quit', 'discard choices']], \] " Each key is highlighted by its position, labels may hold any word let lines = [] @@ -267,7 +267,7 @@ function! s:OpenHelp() continue endif call add(positions, [len(lines) + 1, len(line) + 1, len(key)]) - let line .= printf('%-3s %-18s ', key, label) + let line .= printf('%-3s %-16s ', key, label) endfor call add(lines, substitute(line, '\s\+$', '', '')) endfor diff --git a/doc/vim-project.txt b/doc/vim-project.txt index 0d8bf10..8eb7037 100644 --- a/doc/vim-project.txt +++ b/doc/vim-project.txt @@ -275,9 +275,9 @@ the conflict under the cursor, and are listed at the bottom of the window = 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 - w finish: write the file and git add it. Refused while + w accept: write the file and git add it. Refused while conflicts are unresolved - q close without writing + 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 diff --git a/test/cases/git_merge_test.vim b/test/cases/git_merge_test.vim index e8e2091..ee5fd4f 100644 --- a/test/cases/git_merge_test.vim +++ b/test/cases/git_merge_test.vim @@ -84,7 +84,7 @@ function! Test_resolve_conflicts_in_merge_window() 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')), 'w save and git add') + call Helper_AssertContains(join(s:Lines('vim_project_merge_keys')), 'w accept, git add') call assert_equal('(unresolved)', getline(2)) " Finishing with conflicts left keeps the window diff --git a/test/demo_merge.sh b/test/demo_merge.sh index 123d5d9..870e8de 100755 --- a/test/demo_merge.sh +++ b/test/demo_merge.sh @@ -76,7 +76,7 @@ cat < take theirs = both e edit by hand u undo - ]c / [c move between conflicts w save and git add q quit + ]c / [c move between conflicts w accept, git add q discard choices A in the local changes aborts the $cmd. Run this script again to start over. EOF From 294f40b81933708f5e5b11b4482e90e19a9a4708 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Tue, 27 Oct 2026 03:10:06 +0000 Subject: [PATCH 10/16] Accept with q too and discard with Q in the merge window Like the commit message, where closing commits, q accepts the result. It is refused while conflicts are unresolved. Q discards the choices. Claude-Session: https://claude.ai/code/session_01BwofrMkZ4QpGsCYYSgncrd --- README.md | 2 +- autoload/project/config.vim | 3 ++- autoload/project/git_merge.vim | 23 +++++++++++++++++++---- doc/vim-project.txt | 7 ++++--- test/cases/git_merge_test.vim | 29 ++++++++++++++++++++++++++++- test/demo_merge.sh | 2 +- 6 files changed, 55 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 9caa7b9..771e2f2 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ let g:vim_project_local_config = { | 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, w 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. +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, w or 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 diff --git a/autoload/project/config.vim b/autoload/project/config.vim index f172f9c..4ad6265 100644 --- a/autoload/project/config.vim +++ b/autoload/project/config.vim @@ -118,7 +118,8 @@ let s:default.git_merge_mappings = { \'all_yours': 'L', \'all_theirs': 'R', \'finish': 'w', - \'quit': 'q', + \'finish_and_quit': 'q', + \'quit': 'Q', \} let s:default.file_open_types = { \'': 'edit', diff --git a/autoload/project/git_merge.vim b/autoload/project/git_merge.vim index dc4ec28..a763574 100644 --- a/autoload/project/git_merge.vim +++ b/autoload/project/git_merge.vim @@ -262,8 +262,8 @@ function! s:OpenHelp() for row in rows let line = ' ' for [name, label] in row - let key = get(mappings, name, '') - if empty(key) || key ==? '' + let key = s:GetHelpKey(mappings, name) + if empty(key) continue endif call add(positions, [len(lines) + 1, len(line) + 1, len(key)]) @@ -301,11 +301,24 @@ function! s:SetupPane(name) call s:AddMapping(mappings.all_yours, 'TakeAll("left")') call s:AddMapping(mappings.all_theirs, 'TakeAll("right")') call s:AddMapping(mappings.finish, 'Finish()') + " Like the commit message, closing accepts it + call s:AddMapping(mappings.finish_and_quit, 'Finish()') call s:AddMapping(mappings.quit, 'Quit()') endfunction +function! s:IsMapped(key) + return !empty(a:key) && a:key !=? '' +endfunction + +" Key of a:name in the help, e.g. 'w/q' for accepting +function! s:GetHelpKey(mappings, name) + let names = a:name == 'finish' ? ['finish', 'finish_and_quit'] : [a:name] + let keys = filter(map(names, 'get(a:mappings, v:val, "")'), 's:IsMapped(v:val)') + return join(keys, '/') +endfunction + function! s:AddMapping(key, call) - if a:key ==? '' || empty(a:key) + if !s:IsMapped(a:key) return endif let key = a:key == '<' ? '' : a:key @@ -579,7 +592,9 @@ function! s:Finish() let unresolved = s:CountUnresolved() if unresolved call s:JumpToConflict(1, 1) - call project#Warn(unresolved.' conflicts left unresolved') + let quit = get(project#config#Get('git_merge_mappings'), 'quit', '') + call project#Warn(unresolved.' conflicts left unresolved' + \.(s:IsMapped(quit) ? ', '.quit.' to discard the choices' : '')) return endif diff --git a/doc/vim-project.txt b/doc/vim-project.txt index 8eb7037..99e6bcf 100644 --- a/doc/vim-project.txt +++ b/doc/vim-project.txt @@ -275,9 +275,10 @@ the conflict under the cursor, and are listed at the bottom of the window = 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 - w accept: write the file and git add it. Refused while - conflicts are unresolved - q discard: close, the file stays as it was + w q accept: write the file, git add it and close. Refused + while conflicts are unresolved. Like the commit message, + q 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 diff --git a/test/cases/git_merge_test.vim b/test/cases/git_merge_test.vim index ee5fd4f..a5ef6a4 100644 --- a/test/cases/git_merge_test.vim +++ b/test/cases/git_merge_test.vim @@ -84,7 +84,7 @@ function! Test_resolve_conflicts_in_merge_window() 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')), 'w accept, git add') + call Helper_AssertContains(join(s:Lines('vim_project_merge_keys')), 'w/q accept, git add') call assert_equal('(unresolved)', getline(2)) " Finishing with conflicts left keeps the window @@ -161,6 +161,33 @@ function! Test_edit_conflict_by_hand() 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_abort_merge() let repo = s:OpenConflict('merge abort') try diff --git a/test/demo_merge.sh b/test/demo_merge.sh index 870e8de..c68a7fd 100755 --- a/test/demo_merge.sh +++ b/test/demo_merge.sh @@ -76,7 +76,7 @@ cat < take theirs = both e edit by hand u undo - ]c / [c move between conflicts w accept, git add q discard choices + ]c / [c move between conflicts w/q accept, git add Q discard choices A in the local changes aborts the $cmd. Run this script again to start over. EOF From 5406260648988f1a63c1225ca7951dc37774889d Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Tue, 27 Oct 2026 08:43:30 +0000 Subject: [PATCH 11/16] Fetch remotes in the background when the branch list opens New remote branches only showed up after a git fetch in a terminal. The list now runs git fetch --all --prune as a job when it opens and shows the branches again once it is done. git_branch_fetch turns it off. Claude-Session: https://claude.ai/code/session_01BwofrMkZ4QpGsCYYSgncrd --- README.md | 2 +- autoload/project/config.vim | 1 + autoload/project/git_branch.vim | 71 ++++++++++++++++++++++++++++++++- doc/vim-project.txt | 7 +++- test/cases/git_branch_test.vim | 25 ++++++++++++ 5 files changed, 102 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 771e2f2..50f49c4 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ Merge conflicts: a pull or branch merge that stops on conflicts opens the Unmerg - `: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 4ad6265..2478315 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, \} diff --git a/autoload/project/git_branch.vim b/autoload/project/git_branch.vim index 430f31a..01cf9cb 100644 --- a/autoload/project/git_branch.vim +++ b/autoload/project/git_branch.vim @@ -25,6 +25,12 @@ function! s:Highlight(state) endfunction function! s:Init(input) + 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 +42,69 @@ 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 + +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 + + let s:list = s:LoadBranches() + call s:Update(project#list#State().user_input) + call project#list#RedrawInputLine() + redraw endfunction function! s:Update(input) diff --git a/doc/vim-project.txt b/doc/vim-project.txt index 99e6bcf..40e0b3b 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. @@ -317,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. 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 From 3ea64414aa9dc732ae9779019d89c42a9a881f48 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Tue, 27 Oct 2026 08:43:58 +0000 Subject: [PATCH 12/16] Accept only with q in the merge window w next to q read as two ways to do different things. q accepts, like closing the commit message commits; Q discards. Claude-Session: https://claude.ai/code/session_01BwofrMkZ4QpGsCYYSgncrd --- README.md | 2 +- autoload/project/config.vim | 3 +-- autoload/project/git_merge.vim | 14 +++++--------- doc/vim-project.txt | 4 ++-- test/cases/git_merge_test.vim | 8 ++++---- test/demo_merge.sh | 2 +- 6 files changed, 14 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 50f49c4..68a06ab 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ let g:vim_project_local_config = { | 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, w or 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. +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 diff --git a/autoload/project/config.vim b/autoload/project/config.vim index 2478315..6672a5b 100644 --- a/autoload/project/config.vim +++ b/autoload/project/config.vim @@ -118,8 +118,7 @@ let s:default.git_merge_mappings = { \'previous': '[c', \'all_yours': 'L', \'all_theirs': 'R', - \'finish': 'w', - \'finish_and_quit': 'q', + \'finish': 'q', \'quit': 'Q', \} let s:default.file_open_types = { diff --git a/autoload/project/git_merge.vim b/autoload/project/git_merge.vim index a763574..bc0804c 100644 --- a/autoload/project/git_merge.vim +++ b/autoload/project/git_merge.vim @@ -197,7 +197,7 @@ function! project#git_merge#Open(file) call s:Render() call s:JumpToConflict(1, 1) if empty(s:Conflicts()) - call project#Info('No conflicting lines left, press w to mark it resolved') + call project#Info('No conflicting lines left, press q to accept it') endif endfunction @@ -301,8 +301,6 @@ function! s:SetupPane(name) call s:AddMapping(mappings.all_yours, 'TakeAll("left")') call s:AddMapping(mappings.all_theirs, 'TakeAll("right")') call s:AddMapping(mappings.finish, 'Finish()') - " Like the commit message, closing accepts it - call s:AddMapping(mappings.finish_and_quit, 'Finish()') call s:AddMapping(mappings.quit, 'Quit()') endfunction @@ -310,11 +308,9 @@ function! s:IsMapped(key) return !empty(a:key) && a:key !=? '' endfunction -" Key of a:name in the help, e.g. 'w/q' for accepting function! s:GetHelpKey(mappings, name) - let names = a:name == 'finish' ? ['finish', 'finish_and_quit'] : [a:name] - let keys = filter(map(names, 'get(a:mappings, v:val, "")'), 's:IsMapped(v:val)') - return join(keys, '/') + let key = get(a:mappings, a:name, '') + return s:IsMapped(key) ? key : '' endfunction function! s:AddMapping(key, call) @@ -416,7 +412,7 @@ function! s:UpdateStatusLines() 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, w to finish', + \unresolved ? unresolved.' unresolved' : 'all resolved, q 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) @@ -508,7 +504,7 @@ function! s:Take(state) call s:Render() call s:CursorTo(idx) if !empty(a:state) && !s:JumpToConflict(1, 1) - call project#Info('All conflicts resolved, press w to save and mark it resolved') + call project#Info('All conflicts resolved, press q to accept') endif endfunction diff --git a/doc/vim-project.txt b/doc/vim-project.txt index 40e0b3b..dd2e5bd 100644 --- a/doc/vim-project.txt +++ b/doc/vim-project.txt @@ -277,9 +277,9 @@ the conflict under the cursor, and are listed at the bottom of the window = 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 - w q accept: write the file, git add it and close. Refused + q accept: write the file, git add it and close. Refused while conflicts are unresolved. Like the commit message, - q accepts + 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 diff --git a/test/cases/git_merge_test.vim b/test/cases/git_merge_test.vim index a5ef6a4..c224973 100644 --- a/test/cases/git_merge_test.vim +++ b/test/cases/git_merge_test.vim @@ -84,11 +84,11 @@ function! Test_resolve_conflicts_in_merge_window() 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')), 'w/q accept, git add') + call Helper_AssertContains(join(s:Lines('vim_project_merge_keys')), 'q accept, git add') call assert_equal('(unresolved)', getline(2)) " Finishing with conflicts left keeps the window - normal w + normal q call assert_equal('vim_project_merge_result', bufname('%')) " Take yours, then the cursor moves to the next conflict: take theirs @@ -106,7 +106,7 @@ function! Test_resolve_conflicts_in_merge_window() 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('wn', 'xt') + 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()) @@ -123,7 +123,7 @@ function! Test_finish_continues_the_merge() " :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('wy', 'xt') + call feedkeys('qy', 'xt') call assert_equal({}, project#git_merge#GetState()) call assert_match('Merge', s:Git(repo, 'log -1 --format=%s')) finally diff --git a/test/demo_merge.sh b/test/demo_merge.sh index c68a7fd..d722616 100755 --- a/test/demo_merge.sh +++ b/test/demo_merge.sh @@ -76,7 +76,7 @@ cat < take theirs = both e edit by hand u undo - ]c / [c move between conflicts w/q accept, git add Q discard choices + ]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 From a50a5f4672d3328874ee2b660e60b20bdbda85cc Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Tue, 27 Oct 2026 08:50:30 +0000 Subject: [PATCH 13/16] Name the configured keys in the merge window messages The status line and messages said q and w even when git_merge_mappings set other keys. Claude-Session: https://claude.ai/code/session_01BwofrMkZ4QpGsCYYSgncrd --- autoload/project/git_merge.vim | 13 +++++++++---- test/cases/git_merge_test.vim | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/autoload/project/git_merge.vim b/autoload/project/git_merge.vim index bc0804c..9a143ab 100644 --- a/autoload/project/git_merge.vim +++ b/autoload/project/git_merge.vim @@ -197,7 +197,7 @@ function! project#git_merge#Open(file) call s:Render() call s:JumpToConflict(1, 1) if empty(s:Conflicts()) - call project#Info('No conflicting lines left, press q to accept it') + call project#Info('No conflicting lines left, press '.s:GetMergeKey('finish').' to accept it') endif endfunction @@ -308,6 +308,11 @@ 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 : '' @@ -412,7 +417,7 @@ function! s:UpdateStatusLines() 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, q to accept', + \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) @@ -504,7 +509,7 @@ function! s:Take(state) call s:Render() call s:CursorTo(idx) if !empty(a:state) && !s:JumpToConflict(1, 1) - call project#Info('All conflicts resolved, press q to accept') + call project#Info('All conflicts resolved, press '.s:GetMergeKey('finish').' to accept') endif endfunction @@ -588,7 +593,7 @@ function! s:Finish() let unresolved = s:CountUnresolved() if unresolved call s:JumpToConflict(1, 1) - let quit = get(project#config#Get('git_merge_mappings'), 'quit', '') + let quit = s:GetMergeKey('quit') call project#Warn(unresolved.' conflicts left unresolved' \.(s:IsMapped(quit) ? ', '.quit.' to discard the choices' : '')) return diff --git a/test/cases/git_merge_test.vim b/test/cases/git_merge_test.vim index c224973..a2d9741 100644 --- a/test/cases/git_merge_test.vim +++ b/test/cases/git_merge_test.vim @@ -188,6 +188,26 @@ function! Test_q_accepts_Q_discards() 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 From 1588ddb850e39c5540ddae5a521fb9978f1fef50 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Tue, 27 Oct 2026 10:53:38 +0000 Subject: [PATCH 14/16] Update the conflict number as the cursor moves in the merge window Claude-Session: https://claude.ai/code/session_01BwofrMkZ4QpGsCYYSgncrd --- autoload/project/git_merge.vim | 6 ++++++ test/cases/git_merge_test.vim | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/autoload/project/git_merge.vim b/autoload/project/git_merge.vim index 9a143ab..556e68b 100644 --- a/autoload/project/git_merge.vim +++ b/autoload/project/git_merge.vim @@ -302,6 +302,9 @@ function! s:SetupPane(name) 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) @@ -412,6 +415,9 @@ function! s:Render() endfunction function! s:UpdateStatusLines() + if empty(s:merge) + return + endif let total = len(s:Conflicts()) let current = s:GetConflictNumber() let unresolved = s:CountUnresolved() diff --git a/test/cases/git_merge_test.vim b/test/cases/git_merge_test.vim index a2d9741..41fb4c5 100644 --- a/test/cases/git_merge_test.vim +++ b/test/cases/git_merge_test.vim @@ -87,6 +87,17 @@ function! Test_resolve_conflicts_in_merge_window() 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('%')) From c8e2078192d0996af5442f962366b1c246c8c7fc Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Tue, 27 Oct 2026 13:33:35 +0000 Subject: [PATCH 15/16] Install neovim from its release in Basic-Test ubuntu-latest moved to 26.04, which the neovim PPA does not build for, so adding it failed the job before any test ran. Claude-Session: https://claude.ai/code/session_01BwofrMkZ4QpGsCYYSgncrd --- .github/workflows/main.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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: | From 185ce0ed0d294453e8278702cda74216899f9a70 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Tue, 27 Oct 2026 13:36:30 +0000 Subject: [PATCH 16/16] Refresh the branch list after fetch only when a branch moved Each fetch redrew the list even when nothing changed, flashing it and the cursor, plus a redraw that cleared the input line. Now the refs are compared first, and a failed reload keeps the list as it was instead of leaving it empty. Claude-Session: https://claude.ai/code/session_01BwofrMkZ4QpGsCYYSgncrd --- autoload/project/git_branch.vim | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/autoload/project/git_branch.vim b/autoload/project/git_branch.vim index 01cf9cb..701ab31 100644 --- a/autoload/project/git_branch.vim +++ b/autoload/project/git_branch.vim @@ -25,6 +25,7 @@ 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() @@ -51,6 +52,7 @@ endfunction " 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 @@ -101,10 +103,27 @@ function! s:OnFetched(status, ctx) return endif - let s:list = s:LoadBranches() + " 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() - redraw +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)