Skip to content

Commit a5d0549

Browse files
authored
Merge pull request #704 from seeM/git-hooks
git merge hooks: automatically resolve conflicts and render markers as separate cells
2 parents 31a8031 + ee1b587 commit a5d0549

File tree

6 files changed

+317
-9
lines changed

6 files changed

+317
-9
lines changed

nbdev/_modidx.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
'nbdev_update=nbdev.sync:nbdev_update\n'
1010
'nbdev_export=nbdev.doclinks:nbdev_export\n'
1111
'nbdev_fix=nbdev.merge:nbdev_fix\n'
12+
'nbdev_merge=nbdev.merge:nbdev_merge\n'
1213
'nbdev_trust=nbdev.clean:nbdev_trust\n'
1314
'nbdev_clean=nbdev.clean:nbdev_clean\n'
1415
'nbdev_install_hooks=nbdev.clean:nbdev_install_hooks\n'
@@ -120,6 +121,7 @@
120121
'nbdev.maker.update_var': 'https://nbdev.fast.ai/maker.html#update_var'},
121122
'nbdev.merge': { 'nbdev.merge.conf_re': 'https://nbdev.fast.ai/merge.html#conf_re',
122123
'nbdev.merge.nbdev_fix': 'https://nbdev.fast.ai/merge.html#nbdev_fix',
124+
'nbdev.merge.nbdev_merge': 'https://nbdev.fast.ai/merge.html#nbdev_merge',
123125
'nbdev.merge.unpatch': 'https://nbdev.fast.ai/merge.html#unpatch'},
124126
'nbdev.migrate': { 'nbdev.migrate.migrate_md_fm': 'https://nbdev.fast.ai/migrate.html#migrate_md_fm',
125127
'nbdev.migrate.migrate_nb_fm': 'https://nbdev.fast.ai/migrate.html#migrate_nb_fm',

nbdev/clean.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,9 @@ def _nested_setdefault(o, attr, default):
140140
# %% ../nbs/11_clean.ipynb 30
141141
@call_parse
142142
def nbdev_install_hooks():
143-
"Install Jupyter and git hooks to clean notebooks on save and trust notebooks automatically"
143+
"Install Jupyter and git hooks to automatically clean, trust, and fix merge conflicts in notebooks"
144144
cfg_path = Path.home()/'.jupyter'
145+
cfg_path.mkdir(exist_ok=True)
145146
cfg_fns = [cfg_path/f'jupyter_{o}_config.json' for o in ('notebook','server')]
146147
attr,hook = 'ContentsManager.pre_save_hook','nbdev.clean.clean_jupyter'
147148
for fn in cfg_fns:
@@ -161,4 +162,21 @@ def nbdev_install_hooks():
161162
hook_path.mkdir(parents=True, exist_ok=True)
162163
fn.write_text("#!/bin/bash\nnbdev_trust")
163164
os.chmod(fn, os.stat(fn).st_mode | stat.S_IEXEC)
165+
166+
(path/'.gitconfig').write_text('''# Generated by nbdev_install_hooks
167+
#
168+
# If you need to disable this instrumentation do:
169+
# git config --local --unset include.path
170+
#
171+
# To restore:
172+
# git config --local include.path .gitconfig
173+
#
174+
[merge "nbdev-merge"]
175+
name = resolve conflicts with nbdev_fix
176+
driver = nbdev_merge %O %A %B %P
177+
''')
178+
cmd = "git config --local include.path ../.gitconfig"
179+
run(cmd)
180+
(nb_path/'.gitattributes').write_text("*.ipynb merge=nbdev-merge\n")
181+
164182
print("Hooks are installed.")

nbdev/merge.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/06_merge.ipynb.
22

33
# %% auto 0
4-
__all__ = ['conf_re', 'unpatch', 'nbdev_fix']
4+
__all__ = ['conf_re', 'nbdev_fix', 'unpatch', 'nbdev_merge']
55

66
# %% ../nbs/06_merge.ipynb 2
77
from .imports import *
@@ -13,6 +13,7 @@
1313
from fastcore.script import *
1414
from fastcore import shutil
1515

16+
import subprocess
1617
from difflib import SequenceMatcher
1718

1819
# %% ../nbs/06_merge.ipynb 16
@@ -53,8 +54,7 @@ def _merge_cells(a, b, brancha, branchb, theirs):
5354
return res,conflict
5455

5556
# %% ../nbs/06_merge.ipynb 23
56-
@call_parse
57-
def nbdev_fix(nbname:str, # Notebook filename to fix
57+
def _nbdev_fix(nbname:str, # Notebook filename to fix
5858
outname:str=None, # Filename of output notebook (defaults to `nbname`)
5959
nobackup:bool=True, # Do not backup `nbname` to `nbname`.bak if `outname` not provided
6060
theirs:bool=False, # Use their outputs and metadata instead of ours
@@ -73,3 +73,29 @@ def nbdev_fix(nbname:str, # Notebook filename to fix
7373
if conflict: print("One or more conflict remains in the notebook, please inspect manually.")
7474
else: print("Successfully merged conflicts!")
7575
return conflict
76+
77+
# %% ../nbs/06_merge.ipynb 24
78+
nbdev_fix = call_parse(_nbdev_fix)
79+
80+
# %% ../nbs/06_merge.ipynb 28
81+
def _only(o):
82+
"Return the only item of `o`, raise if `o` doesn't have exactly one item"
83+
it = iter(o)
84+
try: res = next(it)
85+
except StopIteration: raise ValueError('iterable has 0 items') from None
86+
try: next(it)
87+
except StopIteration: return res
88+
raise ValueError(f'iterable has more than 1 item')
89+
90+
# %% ../nbs/06_merge.ipynb 30
91+
def _git_branch_merge(): return _only(v for k,v in os.environ.items() if k.startswith('GITHEAD'))
92+
93+
# %% ../nbs/06_merge.ipynb 31
94+
@call_parse
95+
def nbdev_merge(base:str, ours:str, theirs:str, path:str):
96+
"Git merge driver for notebooks"
97+
proc = subprocess.run(f'git merge-file -L HEAD -L BASE -L {_git_branch_merge()} {ours} {base} {theirs}',
98+
shell=True, capture_output=True, text=True)
99+
if proc.returncode == 0: return
100+
theirs = str2bool(os.environ.get('THEIRS', False))
101+
return _nbdev_fix(ours, theirs=theirs)

nbs/06_merge.ipynb

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"from fastcore.script import *\n",
3737
"from fastcore import shutil\n",
3838
"\n",
39+
"import subprocess\n",
3940
"from difflib import SequenceMatcher"
4041
]
4142
},
@@ -405,8 +406,7 @@
405406
"outputs": [],
406407
"source": [
407408
"#|export\n",
408-
"@call_parse\n",
409-
"def nbdev_fix(nbname:str, # Notebook filename to fix\n",
409+
"def _nbdev_fix(nbname:str, # Notebook filename to fix\n",
410410
" outname:str=None, # Filename of output notebook (defaults to `nbname`)\n",
411411
" nobackup:bool=True, # Do not backup `nbname` to `nbname`.bak if `outname` not provided\n",
412412
" theirs:bool=False, # Use their outputs and metadata instead of ours\n",
@@ -427,6 +427,16 @@
427427
" return conflict"
428428
]
429429
},
430+
{
431+
"cell_type": "code",
432+
"execution_count": null,
433+
"metadata": {},
434+
"outputs": [],
435+
"source": [
436+
"#|export\n",
437+
"nbdev_fix = call_parse(_nbdev_fix)"
438+
]
439+
},
430440
{
431441
"cell_type": "markdown",
432442
"metadata": {},
@@ -456,6 +466,85 @@
456466
"os.unlink('tmp.ipynb')"
457467
]
458468
},
469+
{
470+
"cell_type": "markdown",
471+
"metadata": {},
472+
"source": [
473+
"## Git merge driver"
474+
]
475+
},
476+
{
477+
"cell_type": "code",
478+
"execution_count": null,
479+
"metadata": {},
480+
"outputs": [],
481+
"source": [
482+
"#|export\n",
483+
"def _only(o):\n",
484+
" \"Return the only item of `o`, raise if `o` doesn't have exactly one item\"\n",
485+
" it = iter(o)\n",
486+
" try: res = next(it)\n",
487+
" except StopIteration: raise ValueError('iterable has 0 items') from None\n",
488+
" try: next(it)\n",
489+
" except StopIteration: return res\n",
490+
" raise ValueError(f'iterable has more than 1 item')"
491+
]
492+
},
493+
{
494+
"cell_type": "code",
495+
"execution_count": null,
496+
"metadata": {},
497+
"outputs": [],
498+
"source": [
499+
"#|hide\n",
500+
"test_fail(lambda: _only([]), contains='iterable has 0 items')\n",
501+
"test_eq(_only([0]), 0)\n",
502+
"test_fail(lambda: _only([0,1]), contains='iterable has more than 1 item')"
503+
]
504+
},
505+
{
506+
"cell_type": "code",
507+
"execution_count": null,
508+
"metadata": {},
509+
"outputs": [],
510+
"source": [
511+
"#|export\n",
512+
"def _git_branch_merge(): return _only(v for k,v in os.environ.items() if k.startswith('GITHEAD'))"
513+
]
514+
},
515+
{
516+
"cell_type": "code",
517+
"execution_count": null,
518+
"metadata": {},
519+
"outputs": [],
520+
"source": [
521+
"#|export\n",
522+
"@call_parse\n",
523+
"def nbdev_merge(base:str, ours:str, theirs:str, path:str):\n",
524+
" \"Git merge driver for notebooks\"\n",
525+
" proc = subprocess.run(f'git merge-file -L HEAD -L BASE -L {_git_branch_merge()} {ours} {base} {theirs}',\n",
526+
" shell=True, capture_output=True, text=True)\n",
527+
" if proc.returncode == 0: return\n",
528+
" theirs = str2bool(os.environ.get('THEIRS', False))\n",
529+
" return _nbdev_fix(ours, theirs=theirs)"
530+
]
531+
},
532+
{
533+
"cell_type": "markdown",
534+
"metadata": {},
535+
"source": [
536+
"This implements a [git merge driver](https://git-scm.com/docs/gitattributes#_defining_a_custom_merge_driver) for notebooks that automatically resolves conflicting metadata and outputs, and splits remaining conflicts as separate cells so that the notebook can be viewed and fixed in Jupyter. The easiest way to install it is by running `nbdev_install_hooks`."
537+
]
538+
},
539+
{
540+
"cell_type": "markdown",
541+
"metadata": {},
542+
"source": [
543+
"This works by first running Git's default merge driver, and then `nbdev_fix` if there are still conflicts. You can set `nbdev_fix`'s `theirs` argument using the `THEIRS` environment variable, for example:\n",
544+
"\n",
545+
" THEIRS=True git merge branch"
546+
]
547+
},
459548
{
460549
"cell_type": "markdown",
461550
"metadata": {},

0 commit comments

Comments
 (0)