From 6e2c06f16a59c988f25d3a8d02eca5b8cd657b60 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Wed, 28 Oct 2026 06:26:24 +0000 Subject: [PATCH 1/5] Name the branches of a rebase in the merge window titles During a rebase HEAD is detached, so the upstream title showed a bare hash and yours only the commit. Take the branch being rebased and the commit it is rebased onto from what git saves for the rebase. Tests run a real rebase and cherry-pick through the window and continue them. Claude-Session: https://claude.ai/code/session_01BwofrMkZ4QpGsCYYSgncrd --- autoload/project/git_merge.vim | 35 ++++++++++++++++++------ test/cases/git_merge_test.vim | 49 +++++++++++++++++++++++++++++++--- 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/autoload/project/git_merge.vim b/autoload/project/git_merge.vim index 556e68b..9546db8 100644 --- a/autoload/project/git_merge.vim +++ b/autoload/project/git_merge.vim @@ -34,11 +34,13 @@ function! project#git_merge#GetState() 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 dir in ['rebase-merge', 'rebase-apply'] + if isdirectory(git_dir.'/'.dir) + return {'name': 'rebase', 'yours': 3, 'theirs': 2, 'rebase_dir': git_dir.'/'.dir, + \'continue': 'git -c core.editor=true rebase --continue', + \'abort': 'git rebase --abort'} + endif + endfor for [name, head] in [['merge', 'MERGE_HEAD'], ['cherry-pick', 'CHERRY_PICK_HEAD'], ['revert', 'REVERT_HEAD']] if filereadable(git_dir.'/'.head) let continue = name == 'merge' @@ -59,8 +61,13 @@ endfunction 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] + " HEAD is detached: name the branches from what git saved for the rebase + let branch = s:ReadRebaseFile(a:state, 'head-name') + let branch = substitute(branch, '^refs/heads/', '', '') + let rebase_head = s:ShortHash('REBASE_HEAD') + let yours = empty(branch) ? rebase_head : branch.' '.rebase_head + let onto = s:Describe(s:ReadRebaseFile(a:state, 'onto')) + return ['Yours (being rebased) '.yours, 'Upstream (rebasing onto) '.onto] endif if has_key(a:state, 'head') return ['Yours '.head, 'Theirs '.s:Describe(a:state.head)] @@ -69,9 +76,21 @@ function! s:GetTitles(state) endfunction " Branch name or short hash of a revision, like 'main a1b2c3d' +function! s:ReadRebaseFile(state, name) + let path = get(a:state, 'rebase_dir', '').'/'.a:name + return filereadable(path) ? get(readfile(path), 0, '') : '' +endfunction + +function! s:ShortHash(rev) + return get(project#RunShellCmd('git rev-parse --short '.shellescape(a:rev), 0), 0, '') +endfunction + function! s:Describe(rev) + if empty(a:rev) + return '' + endif let rev = shellescape(a:rev) - let hash = get(project#RunShellCmd('git rev-parse --short '.rev, 0), 0, '') + let hash = s:ShortHash(a:rev) if empty(hash) return '' endif diff --git a/test/cases/git_merge_test.vim b/test/cases/git_merge_test.vim index 41fb4c5..6bfcb8c 100644 --- a/test/cases/git_merge_test.vim +++ b/test/cases/git_merge_test.vim @@ -11,7 +11,9 @@ 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) +function! s:OpenConflict(name, ...) + " 'merge', 'rebase' or 'cherry-pick' feature + let operation = get(a:000, 0, 'merge') let base = Helper_TmpDir(a:name) let repo = base.'/proj' call mkdir(repo, 'p') @@ -27,7 +29,7 @@ function! s:OpenConflict(name) 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') + call s:Git(repo, operation.' feature') let project = {'name': 'proj', 'path': base, 'fullpath': repo, 'note': '', 'option': {}} call Helper_Call('autoload/project.vim', 'InitProjectConfig', [project]) @@ -36,7 +38,9 @@ function! s:OpenConflict(name) endfunction function! s:Close(repo) - call s:Git(a:repo, 'merge --abort') + for operation in ['merge', 'rebase', 'cherry-pick'] + call s:Git(a:repo, operation.' --abort') + endfor silent! call s:Call('CloseWindows') silent! execute 'bwipeout!' bufnr('local_changes') call project#QuitProject() @@ -230,3 +234,42 @@ function! Test_abort_merge() call s:Close(repo) endtry endfunction + +function! Test_rebase_shows_the_rebased_commit_as_yours() + let repo = s:OpenConflict('merge rebase', 'rebase') + try + call assert_equal('rebase', project#git_merge#GetState().name) + call project#git_merge#Open('file.txt') + " git's ours is feature, being rebased onto: yours is still on the left + 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')) + let titles = s:Call('GetTitles', project#git_merge#GetState()) + call assert_match('^Yours (being rebased) .* ', titles[0]) + call assert_match('^Upstream (rebasing onto) feature ', titles[1]) + + " Continue finishes the rebase + call feedkeys('Lyqy', 'xt') + call assert_equal({}, project#git_merge#GetState()) + call assert_equal(['a', 'B yours', 'c', 'D yours', 'e'], readfile(repo.'/file.txt')) + call assert_equal(['mine', 'feature'], split(s:Git(repo, 'log --format=%s -2'), "\n")) + finally + call s:Close(repo) + endtry +endfunction + +function! Test_cherry_pick_conflict_and_continue() + let repo = s:OpenConflict('merge cherry-pick', 'cherry-pick') + try + call assert_equal('cherry-pick', project#git_merge#GetState().name) + call project#git_merge#Open('file.txt') + call assert_equal(['a', 'B yours', 'c', 'D yours', 'e'], s:Lines('vim_project_merge_yours')) + call assert_match('^Theirs ', s:Call('GetTitles', project#git_merge#GetState())[1]) + + call feedkeys('Ryqy', 'xt') + call assert_equal({}, project#git_merge#GetState()) + call assert_equal(['a', 'B theirs', 'c', 'D theirs', 'e'], readfile(repo.'/file.txt')) + call assert_equal('feature', trim(s:Git(repo, 'log --format=%s -1'))) + finally + call s:Close(repo) + endtry +endfunction From 4d1eba3e8eb7bc277f270566338389ec2899d3b9 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Wed, 28 Oct 2026 06:27:53 +0000 Subject: [PATCH 2/5] Keep local changes across pull with --autostash Like IntelliJ, local changes are put aside before pulling and back after it. When putting them back conflicts, git keeps them in the stash and the merge window opens with them as yours; once resolved they are local changes again. git_pull_autostash turns it off. Claude-Session: https://claude.ai/code/session_01BwofrMkZ4QpGsCYYSgncrd --- README.md | 2 +- autoload/project/config.vim | 1 + autoload/project/git.vim | 8 +++++- autoload/project/git_merge.vim | 33 +++++++++++++++++++-- doc/vim-project.txt | 7 +++++ test/cases/git_test.vim | 52 ++++++++++++++++++++++++++++++++++ 6 files changed, 98 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 68a06ab..0c1116e 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ let g:vim_project_local_config = { | R | roll back the selected files | | 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 | +| u / p / P | pull (keeping local changes with `--autostash`), 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. diff --git a/autoload/project/config.vim b/autoload/project/config.vim index 6672a5b..143ba25 100644 --- a/autoload/project/config.vim +++ b/autoload/project/config.vim @@ -39,6 +39,7 @@ let s:default = { \'new_tasks_post_cmd': '', \'commit_message': '', \'git_branch_fetch': 1, + \'git_pull_autostash': 1, \'find_replace_separator': ' => ', \'debug': 0, \} diff --git a/autoload/project/git.vim b/autoload/project/git.vim index 4a38464..0cd648f 100644 --- a/autoload/project/git.vim +++ b/autoload/project/git.vim @@ -2022,12 +2022,18 @@ endfunction " a:1: called after a successful pull function! s:TryPull(...) let Then = get(a:000, 0, v:null) - call s:RunGitInBackground('git pull', 'Updating...', 'Updated successfully', + " Like IntelliJ, local changes are put aside and back around the pull + let cmd = project#config#Get('git_pull_autostash') ? 'git pull --autostash' : 'git pull' + call s:RunGitInBackground(cmd, 'Updating...', 'Updated successfully', \function('s:OnPulled', [Then])) endfunction function! s:OnPulled(Then) call project#search_files#Reset() + " Putting the local changes back may conflict with what was pulled + if project#git_merge#ShowConflicts() + return + endif if a:Then isnot v:null call a:Then() endif diff --git a/autoload/project/git_merge.vim b/autoload/project/git_merge.vim index 9546db8..a699c62 100644 --- a/autoload/project/git_merge.vim +++ b/autoload/project/git_merge.vim @@ -50,6 +50,12 @@ function! project#git_merge#GetState() \'continue': continue, 'abort': 'git '.name.' --abort'} endif endfor + " Nothing in progress, yet conflicts: applying a stash, like after pull + " --autostash. git's theirs is the stash, your local changes + if !empty(project#git_merge#GetUnmergedFiles()) + \&& !empty(project#RunShellCmd('git stash list -n 1', 0)) + return {'name': 'stash', 'yours': 3, 'theirs': 2} + endif return {} endfunction @@ -72,6 +78,9 @@ function! s:GetTitles(state) if has_key(a:state, 'head') return ['Yours '.head, 'Theirs '.s:Describe(a:state.head)] endif + if get(a:state, 'name', '') == 'stash' + return ['Yours (local changes, stashed)', 'Upstream '.head] + endif return ['Yours '.head, 'Theirs'] endfunction @@ -211,6 +220,7 @@ function! project#git_merge#Open(file) \'segments': s:BuildSegments(yours, base is v:null ? [] : base, theirs), \'left_title': left_title, \'right_title': right_title, + \'state': state, \} call s:OpenWindows() call s:Render() @@ -626,17 +636,23 @@ function! s:Finish() let path = $vim_project.'/'.s:merge.file call writefile(s:GetResultLines(), path) - call project#RunShellCmd('git add -- '.shellescape(s:merge.file)) + let file = shellescape(s:merge.file) + call project#RunShellCmd('git add -- '.file) if v:shell_error return endif + let state = s:merge.state + if get(state, 'name', '') == 'stash' + " Back to a local change, as applying the stash without conflicts would + call project#RunShellCmd('git reset -q -- '.file) + endif let bufnr = bufnr(path) if bufnr != -1 silent! execute 'checktime' bufnr endif call s:CloseWindows() - call s:OnFileResolved() + call s:OnFileResolved(state) endfunction function! s:Quit() @@ -665,12 +681,18 @@ endfunction " Back to the local changes. Once no file is left, offer to finish what git " is in the middle of -function! s:OnFileResolved() +" a:1: the state the file was resolved in +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 + if get(get(a:000, 0, {}), 'name', '') == 'stash' + call project#Info('All conflicts resolved. Your changes are still in the stash too: ' + \.'git stash drop once you are sure') + return + endif let state = project#git_merge#GetState() if empty(state) @@ -706,6 +728,11 @@ function! project#git_merge#Abort() call project#Info('No merge, rebase, cherry-pick or revert in progress') return endif + if !has_key(state, 'abort') + call project#Info('Applying the stash has conflicts. Your changes are safe in it: ' + \.'resolve them, or git checkout the files and git stash pop again later') + return + endif redraw echo 'Abort the '.state.name.' and go back to before it? (y/n) ' let answer = nr2char(getchar()) diff --git a/doc/vim-project.txt b/doc/vim-project.txt index dd2e5bd..00d17e3 100644 --- a/doc/vim-project.txt +++ b/doc/vim-project.txt @@ -288,6 +288,10 @@ 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. +Pull runs with --autostash: local changes are put aside and back around it. +When putting them back conflicts with what was pulled, they stay safe in +the stash and the merge window opens, with your local changes as yours. +Turn it off with "git_pull_autostash". In the diff buffer, jumps to the source line ("git_diff_mappings"). In the changes buffer, opens the file ("git_changes_mappings"). @@ -322,6 +326,9 @@ check_branch_when_use_session 0 git_branch_fetch 1 Fetch all remotes in the background when |:ProjectGitBranch| opens. +git_pull_autostash 1 + Pull with --autostash, keeping local changes across it. Needs git 2.27. + open_root_when_use_session 0 Open the project root even when a session is loaded. diff --git a/test/cases/git_test.vim b/test/cases/git_test.vim index 6218eb0..f54ef00 100644 --- a/test/cases/git_test.vim +++ b/test/cases/git_test.vim @@ -494,3 +494,55 @@ function! Test_GenerateBrief_keeps_the_message_whole_and_the_splitter_on_one_row call assert_equal(revision.message, brief[1]) call assert_true(strdisplaywidth(brief[0]) <= s:Call('GetChangesTextWidth')) endfunction + +" Pull puts local changes aside and back, like IntelliJ +function! Test_pull_keeps_local_changes_with_autostash() + let base = s:Remote('pull-autostash') + call s:WithProject(base.'/mine', function('s:CheckPullAutostash', [base])) +endfunction + +function! s:CheckPullAutostash(base) + let mine = a:base.'/mine' + let theirs = a:base.'/theirs' + let mine_git = 'git -C '.shellescape(mine).' ' + + " A change in a file the pull also changes, without conflicting lines + call writefile(['a', 'b', 'c', 'd', 'e'], theirs.'/shared.txt') + call system('git -C '.shellescape(theirs).' add . && git -C '.shellescape(theirs).' commit -q -m shared && git -C '.shellescape(theirs).' push -q') + call system(mine_git.'pull -q') + call writefile(['a', 'b theirs', 'c', 'd', 'e'], theirs.'/shared.txt') + call system('git -C '.shellescape(theirs).' commit -q -am theirs && git -C '.shellescape(theirs).' push -q') + call writefile(['a', 'b', 'c', 'd', 'e mine'], mine.'/shared.txt') + + call s:Call('TryPull') + call s:WaitGitJob() + call assert_equal(s:RemoteHead(a:base), s:Head(mine)) + call assert_equal(['a', 'b theirs', 'c', 'd', 'e mine'], readfile(mine.'/shared.txt')) + call assert_equal('', system(mine_git.'stash list')) + + " A conflicting change: kept in the stash, resolved in the merge window + call writefile(['a', 'b theirs 2', 'c', 'd', 'e'], theirs.'/shared.txt') + call system('git -C '.shellescape(theirs).' commit -q -am theirs2 && git -C '.shellescape(theirs).' push -q') + call writefile(['a', 'b mine', 'c', 'd', 'e mine'], mine.'/shared.txt') + call s:Call('TryPull') + call s:WaitGitJob() + call assert_equal(s:RemoteHead(a:base), s:Head(mine)) + call assert_equal(['shared.txt'], project#git_merge#GetUnmergedFiles()) + call assert_equal('stash', project#git_merge#GetState().name) + + try + call project#git_merge#Open('shared.txt') + " Yours are the local changes, not what was pulled + call assert_equal(['a', 'b mine', 'c', 'd', 'e mine'], getbufline(bufnr('vim_project_merge_yours'), 1, '$')) + call feedkeys(' Date: Wed, 28 Oct 2026 06:29:34 +0000 Subject: [PATCH 3/5] Show the lines git merged on its own in grey in the merge window Changes made on one side only were merged by git without a mark, looking like lines nobody touched. Diff the base with the merged text to find them, tell by yours whose change each one is, and mark them grey with the side they came from. The demo has one such line per side. Claude-Session: https://claude.ai/code/session_01BwofrMkZ4QpGsCYYSgncrd --- autoload/project/git_merge.vim | 78 +++++++++++++++++++++++++++++++++- doc/vim-project.txt | 3 ++ test/cases/git_merge_test.vim | 11 +++++ test/demo_merge.sh | 13 +++--- 4 files changed, 98 insertions(+), 7 deletions(-) diff --git a/autoload/project/git_merge.vim b/autoload/project/git_merge.vim index a699c62..a5dfa78 100644 --- a/autoload/project/git_merge.vim +++ b/autoload/project/git_merge.vim @@ -168,7 +168,68 @@ function! s:BuildSegments(left, base, right) " It exits with the number of conflicts let output = systemlist(cmd) call delete(dir, 'rf') - return s:ParseMergeOutput(output) + let segments = s:ParseMergeOutput(output) + call s:MarkAutoMerged(segments, a:left, a:base) + return segments +endfunction + +" Mark the lines git merged on its own, which changed on one side only: +" segment.auto maps a line index of a text segment to 'left', 'right' or +" 'both'. Diff the base with the merged text, the conflicts kept as base, +" then tell by yours whose change each one is +function! s:MarkAutoMerged(segments, left, base) + let merged = [] + let starts = [] + for segment in a:segments + call add(starts, len(merged) + 1) + call extend(merged, segment.type == 'text' ? segment.lines : segment.base) + endfor + + let yours = {} + for hunk in s:DiffHunks(a:base, a:left) + let yours[hunk.old] = hunk.lines + endfor + + for hunk in s:DiffHunks(a:base, merged) + let side = get(yours, hunk.old, v:null) == hunk.lines ? 'left' + \: has_key(yours, hunk.old) ? 'both' : 'right' + for lnum in range(hunk.start, hunk.start + len(hunk.lines) - 1) + for idx in range(len(a:segments)) + let segment = a:segments[idx] + let offset = lnum - starts[idx] + if segment.type == 'text' && offset >= 0 && offset < len(segment.lines) + if !has_key(segment, 'auto') + let segment.auto = {} + endif + let segment.auto[offset] = side + endif + endfor + endfor + endfor +endfunction + +" Hunks of changed lines from a:old to a:new: {'old': 'start,count' in +" a:old, 'start': first line in a:new, 'lines': the new lines} +function! s:DiffHunks(old, new) + let dir = tempname() + call mkdir(dir, 'p') + call writefile(a:old, dir.'/old') + call writefile(a:new, dir.'/new') + let output = systemlist('git diff --no-index --no-color --no-ext-diff -U0 -- ' + \.shellescape(dir.'/old').' '.shellescape(dir.'/new')) + call delete(dir, 'rf') + + let hunks = [] + for line in output + let m = matchlist(line, '^@@ -\(\d\+\)\%(,\(\d\+\)\)\? +\(\d\+\)') + if !empty(m) + let num = empty(m[2]) ? 1 : str2nr(m[2]) + call add(hunks, {'old': m[1].','.num, 'start': str2nr(m[3]), 'lines': []}) + elseif !empty(hunks) && line[0] ==# '+' + call add(hunks[-1].lines, line[1:]) + endif + endfor + return hunks endfunction function! s:Conflicts() @@ -367,6 +428,7 @@ function! s:DefineHighlights() hi default ProjectMergePending ctermbg=58 guibg=#4f4000 hi default link ProjectMergeUnused Comment hi default link ProjectMergeFiller NonText + hi default link ProjectMergeAuto Comment let utf8 = &encoding ==# 'utf-8' let signs = { @@ -378,6 +440,9 @@ function! s:DefineHighlights() \'used': [utf8 ? '✓' : 'ok', 'ProjectMergeResolved'], \'unused': ['--', 'ProjectMergeUnused'], \'filler': [' ', 'ProjectMergeFiller'], + \'auto_left': [utf8 ? '◂' : '<~', 'ProjectMergeAuto'], + \'auto_right': [utf8 ? '▸' : '~>', 'ProjectMergeAuto'], + \'auto_both': [utf8 ? '◆' : '<>', 'ProjectMergeAuto'], \} for [name, value] in items(signs) call sign_define('ProjectMerge_'.name, {'text': value[0], 'linehl': value[1], 'texthl': value[1]}) @@ -409,6 +474,17 @@ function! s:Render() for pane in panes call extend(pane, segment.lines) endfor + " Merged by git: grey in the result and on the side it came from + for [offset, side] in items(get(segment, 'auto', {})) + let lnum = start + offset + call add(signs[1], [lnum, 'auto_'.side]) + if side != 'right' + call add(signs[0], [lnum, 'auto_'.side]) + endif + if side != 'left' + call add(signs[2], [lnum, 'auto_'.side]) + endif + endfor call add(s:layout, {'start': start, 'height': len(segment.lines)}) continue endif diff --git a/doc/vim-project.txt b/doc/vim-project.txt index 00d17e3..35e8f6e 100644 --- a/doc/vim-project.txt +++ b/doc/vim-project.txt @@ -269,6 +269,9 @@ in a new tab, like the Merge dialog of IntelliJ IDEA: 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. +Lines git merged on its own, changed on one side only, are grey +("ProjectMergeAuto") and marked with the side they came from: ◂ yours, +▸ theirs. 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"): diff --git a/test/cases/git_merge_test.vim b/test/cases/git_merge_test.vim index 6bfcb8c..9fb02c7 100644 --- a/test/cases/git_merge_test.vim +++ b/test/cases/git_merge_test.vim @@ -273,3 +273,14 @@ function! Test_cherry_pick_conflict_and_continue() call s:Close(repo) endtry endfunction + +function! Test_MarkAutoMerged_tells_whose_change_git_merged() + let base = ['a', 'b', 'c', 'd', 'e', 'f', 'g'] + " Yours changes a and d, theirs changes g and d: d conflicts + let yours = ['A yours', 'b', 'c', 'D yours', 'e', 'f', 'g'] + let theirs = ['a', 'b', 'c', 'D theirs', 'e', 'f', 'G theirs'] + let segments = s:Call('BuildSegments', yours, base, theirs) + call assert_equal(['text', 'conflict', 'text'], map(copy(segments), 'v:val.type')) + call assert_equal({'0': 'left'}, segments[0].auto) + call assert_equal({'2': 'right'}, segments[2].auto) +endfunction diff --git a/test/demo_merge.sh b/test/demo_merge.sh index d722616..4988c90 100755 --- a/test/demo_merge.sh +++ b/test/demo_merge.sh @@ -1,7 +1,8 @@ #!/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 # merge with two conflicts in f.txt, and +# # a line each side changed alone # 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 @@ -31,7 +32,7 @@ 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 +printf 'a\nb\nc\nd\ne\nf\ng\nh\n' > f.txt git add f.txt git commit -q -m init @@ -40,13 +41,13 @@ 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' + printf 'a\nB theirs\nc\nD theirs\ne\nF theirs\ng\nh\n' > f.txt + git commit -q -am 'feature: change b, d and f' 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' +printf 'a\nB yours\nc\nD yours\ne\nf\ng\nH yours\n' > f.txt +git commit -q -am 'main: change b, d and h' cmd=merge [ "$mode" = rebase ] && cmd=rebase From ff4db20bbe8bcfded63ff0a86420a51a4b836408 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Wed, 28 Oct 2026 06:30:16 +0000 Subject: [PATCH 4/5] Compare the hunks in a way Neovim accepts Neovim refuses to compare v:null with a list. Claude-Session: https://claude.ai/code/session_01BwofrMkZ4QpGsCYYSgncrd --- autoload/project/git_merge.vim | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/autoload/project/git_merge.vim b/autoload/project/git_merge.vim index a5dfa78..c14b6ab 100644 --- a/autoload/project/git_merge.vim +++ b/autoload/project/git_merge.vim @@ -191,8 +191,11 @@ function! s:MarkAutoMerged(segments, left, base) endfor for hunk in s:DiffHunks(a:base, merged) - let side = get(yours, hunk.old, v:null) == hunk.lines ? 'left' - \: has_key(yours, hunk.old) ? 'both' : 'right' + if !has_key(yours, hunk.old) + let side = 'right' + else + let side = yours[hunk.old] == hunk.lines ? 'left' : 'both' + endif for lnum in range(hunk.start, hunk.start + len(hunk.lines) - 1) for idx in range(len(a:segments)) let segment = a:segments[idx] From 52f33790ad68365020bbdc998808170c58625ec7 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Wed, 28 Oct 2026 06:40:10 +0000 Subject: [PATCH 5/5] Accept with q and discard with Q in the edit window too The edit window said :w to use it, :q! to cancel while the merge window said q to accept. Both use the configured accept and discard keys now, and the status line once resolved says only what to press, to fit. Claude-Session: https://claude.ai/code/session_01BwofrMkZ4QpGsCYYSgncrd --- autoload/project/git_merge.vim | 39 +++++++++++++++++++++++++++++----- doc/vim-project.txt | 3 ++- test/cases/git_merge_test.vim | 17 +++++++++++++++ 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/autoload/project/git_merge.vim b/autoload/project/git_merge.vim index c14b6ab..f175737 100644 --- a/autoload/project/git_merge.vim +++ b/autoload/project/git_merge.vim @@ -404,6 +404,18 @@ function! s:IsMapped(key) return !empty(a:key) && a:key !=? '' endfunction +" Like 'q to accept, Q to discard', with the configured keys +function! s:AcceptDiscardHint() + let hints = [] + for [name, label] in [['finish', 'accept'], ['quit', 'discard']] + let key = s:GetMergeKey(name) + if !empty(key) + call add(hints, key.' to '.label) + endif + endfor + return join(hints, ', ') +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) @@ -530,9 +542,13 @@ function! s:UpdateStatusLines() 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)) + if unresolved + let status = printf(' %s/%d %d unresolved %%<%s', current ? current : '-', total, + \unresolved, s:EscapeStatus(s:merge.file)) + else + " Short enough for a narrow pane: what to press matters more here + let status = ' done: '.s:AcceptDiscardHint().' %<'.s:EscapeStatus(s:merge.file) + endif 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)) @@ -623,7 +639,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 '.s:GetMergeKey('finish').' to accept') + call project#Info('All conflicts resolved, '.s:AcceptDiscardHint()) endif endfunction @@ -661,7 +677,20 @@ function! s:EditConflict() 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 + " The same keys as the merge window: accept or discard, :w and :q! work too + let accept = s:GetMergeKey('finish') + let discard = s:GetMergeKey('quit') + let hints = [] + if !empty(accept) + execute 'nnoremap '.accept.' :silent write' + call add(hints, accept.' to accept') + endif + if !empty(discard) + execute 'nnoremap '.discard.' :bwipeout!' + call add(hints, discard.' to discard') + endif + let &l:statusline = ' Edit the conflict' + \.(empty(hints) ? ', :w to use it, :q! to cancel' : ', '.join(hints, ', ')) 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()}) diff --git a/doc/vim-project.txt b/doc/vim-project.txt index 35e8f6e..d65ae90 100644 --- a/doc/vim-project.txt +++ b/doc/vim-project.txt @@ -277,7 +277,8 @@ 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 + = take both e edit it by hand, then q to + accept or Q to discard 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 diff --git a/test/cases/git_merge_test.vim b/test/cases/git_merge_test.vim index 9fb02c7..0fa6c92 100644 --- a/test/cases/git_merge_test.vim +++ b/test/cases/git_merge_test.vim @@ -171,6 +171,23 @@ function! Test_edit_conflict_by_hand() call assert_notequal(-1, bufwinnr('vim_project_merge_yours'), cmd) call assert_notequal(-1, bufwinnr('vim_project_merge_theirs'), cmd) endfor + + " q and Q accept and discard, like in the merge window + call cursor(5, 1) + normal e + call assert_match('q to accept, Q to discard', &l:statusline) + call setline(1, ['D by q']) + normal q + sleep 50m + call assert_equal('vim_project_merge_result', bufname('%')) + call assert_equal('D by q', getline(5)) + call cursor(5, 1) + normal e + call setline(1, ['D dropped']) + normal Q + sleep 50m + call assert_equal('vim_project_merge_result', bufname('%')) + call assert_equal('D by q', getline(5)) finally call s:Close(repo) endtry