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 556e68b..f175737 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' @@ -48,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 @@ -59,19 +67,39 @@ 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)] endif + if get(a:state, 'name', '') == 'stash' + return ['Yours (local changes, stashed)', 'Upstream '.head] + endif return ['Yours '.head, 'Theirs'] 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 @@ -140,7 +168,71 @@ 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) + 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] + 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() @@ -192,6 +284,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() @@ -311,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) @@ -338,6 +443,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 = { @@ -349,6 +455,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]}) @@ -380,6 +489,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 @@ -422,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)) @@ -515,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 @@ -553,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()}) @@ -607,17 +744,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() @@ -646,12 +789,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) @@ -687,6 +836,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..d65ae90 100644 --- a/doc/vim-project.txt +++ b/doc/vim-project.txt @@ -269,12 +269,16 @@ 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"): < 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 @@ -288,6 +292,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 +330,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_merge_test.vim b/test/cases/git_merge_test.vim index 41fb4c5..0fa6c92 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() @@ -167,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 @@ -230,3 +251,53 @@ 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 + +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/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(' 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