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'