Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
c9fb49f
Add error handling for merge conflict
jnywong Dec 10, 2025
c0b0dc2
Rename button id
jnywong Dec 10, 2025
93b91de
Update helper text
jnywong Dec 10, 2025
8cd7d68
Pass error help text selector
jnywong Dec 10, 2025
dcb0da6
Add button role to link
jnywong Dec 10, 2025
5bdf5b1
Add backup folder names to error help text
jnywong Dec 10, 2025
ffdcfc5
Increase contrast of toggle text
jnywong Dec 10, 2025
1d58ac3
Add backup parameter
jnywong Dec 10, 2025
3249da6
Wire up handler for backup parameter
jnywong Dec 10, 2025
2ac837e
Use os.rename
jnywong Dec 10, 2025
506cfab
Add backup to folder name
jnywong Dec 10, 2025
af8da3f
Typo
jnywong Dec 10, 2025
d808c64
Use logging and not yield
jnywong Dec 10, 2025
cafa314
Update toggle text
jnywong Dec 10, 2025
c3d5cae
Update timestamp format
jnywong Dec 10, 2025
c801fe7
Drop into parent directory if backup=true so user can see both backup…
jnywong Dec 10, 2025
b72fed2
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Dec 11, 2025
6fc2af4
Fix KeyError when backup parameter is not present
jnywong Dec 12, 2025
3d0be0b
Pass backup parameter to Puller
jnywong Jan 7, 2026
2f65ebf
Test backup api
jnywong Jan 7, 2026
5d70a0f
Test backup strategy
jnywong Jan 7, 2026
57cee43
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 7, 2026
2748e17
Update with backup strategy
jnywong Jan 7, 2026
3832778
Update URL options
jnywong Jan 7, 2026
28c1292
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 7, 2026
7c970b8
Restyle buttons
jnywong Jan 7, 2026
21063da
Add link to docs
jnywong Jan 7, 2026
39fcd33
Add git command
jnywong Jan 7, 2026
14efffa
Clean up print statements
jnywong Jan 7, 2026
691a973
Clean up print statements
jnywong Jan 7, 2026
273890c
Style recommended option as btn-primary
jnywong Jan 12, 2026
e4f9274
Move copy error into error dialog window and place Proceed without sy…
jnywong Jan 12, 2026
c93a8dc
Rearrange ordering of helper message content
jnywong Jan 12, 2026
b1f0767
Remove margin styling
jnywong Jan 12, 2026
af44677
Manually clean up folders
jnywong Jan 13, 2026
68f232a
Remove "recommended" text
jnywong Jan 13, 2026
dd68ae1
Append proceed without syncing button to parent div if errorhelp
jnywong Jan 13, 2026
33b7a93
Pass id to queryselector
jnywong Jan 13, 2026
a07de75
Remove button styling
jnywong Jan 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@
This allows your users to focus on the content without needing to understand `git`
or other version control machinery.

`nbgitpuller` provides {ref}`automatic, opinioned conflict resolution <topic/automatic-merging>`
by using `git` under the hood.
`nbgitpuller` provides [automatic, opinionated conflict resolution](topic/automatic-merging.md) by using `git` under the hood.
It is commonly used to distribute content to multiple users of a JupyterHub, though it works just fine on an individual person's computer, if they have Jupyter installed.

Here's an example of `nbgitpuller` in action:
Expand Down
60 changes: 60 additions & 0 deletions docs/topic/automatic-merging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Automatic Merging Behavior

`nbgitpuller` tries to make sure the end user who clicked the link
**never** has to manually interact with the git repo. This requires us to
make some opinionated choices on how we handle various cases where both the
student (end user) and instructor (author of the repo) repo have modified the
repository.

Here, we describe how we handle the various possible cases each time the
student clicks the nbgitpuller link.

## Case 1: The instructor changed a file that the student has not changed

The student's changes are left alone, and the instructor's changes are pulled
in to the local copy. Most common case. This is also what happens when the
instructor adds a new file / directory.

## Case 2: Student & instructor changed different lines in same file

Very similar to case 1 - the student's changes are left alone, and the
instructor's changes are merged in to the existing local file.

## Case 3: Student & instructor change same lines in same file

In this case, we **always keep the student's changes**. We want to never
accidentally lose a student's changes - `nbgitpuller` will not eat your
homework.

## Case 4: Student deletes file locally, but instructor doesn't

If the student has deleted a file locally, but the file is still present in
the remote repo, the file from the remote repo is pulled into the student's
directory. This enables the use case where a student wants to 'start over'
a file after having made many changes to it. They can simply delete the file,
click the nbgitpuller link again, and get a fresh copy.

## Case 5: Student creates file manually, but instructor adds file with same name

As an example, let's say the student manually creates a file named
`Untitled141.ipynb` in the directory where nbgitpuller has pulled a
repository. At some point afterwards, the instructor creates a file _also_
named `Untitled141.ipynb` and pushes it to the repo.

When the student clicks the nbgitpuller link next, we want to make sure we
don't destroy the student's work. Since they were created in two different
places, the likelihood of them being mergeable is low. So we **rename** the
student's file, and pull the instructor's file. So the student's
`Untitled141.ipynb` file will be renamed to
`Untitled141_<timestamp>.ipynb`, and the instructor's file will be kept at
`Untitled141.ipynb`.

This is a fairly rare case in our experience.

## Case 6: Instructor creates unresolvable merge conflict or student performs a git commit

Suppose the instructor did not read [Content git repository best practices](repo-best-practices.md) and force pushed changes to a repository, or a student accidentally created a git commit. This results in a [divergent git history](https://git-scm.com/book/en/v2/Git-Branching-Branches-in-a-Nutshell#divergent_history) between the instructor and the student's copy of the content, and so the student cannot sync updates from the instructor any longer.

`nbgitpuller` provides a backup strategy where the student's work is copied and renamed to `<repo_name>_backup_<YYYYMMDDHHMMSS>`, and then a fresh copy of the instructor's version of the repository is pulled into the student's working directory.

This option appears as button with the label `Backup and resync` after encountering the unresolvable merge conflict. The student may want to manually copy their work from backed up changes into the new folder.
61 changes: 0 additions & 61 deletions docs/topic/automatic-merging.rst

This file was deleted.

5 changes: 5 additions & 0 deletions docs/topic/url-options.rst
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ You can specify a different parent directory for the clone by setting the enviro
If you require full control over the destination directory, or want to set the directory at runtime in the nbgitpuller link use this parameter.


``backup``
==========

Case 6 in the :doc:`automatic merging behaviour <automatic-merging>` is implemented by setting the URL parameter ``backup=true``. This will rename the current repo with the current working state of the repo to a timestamped backup folder, and then pull in a fresh copy of the git repository from the remote into a new folder.

Deprecated parameters
=====================

Expand Down
2 changes: 1 addition & 1 deletion docs/use.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ This link will contain at least the following information:
The first time a particular student clicks the link, a local copy of the
repository is made for the student. On successive clicks, the latest version
of the remote repository is fetched, and merged automatically with the
student's local copy using a {ref}`series of rules <topic/automatic-merging>`
student's local copy using a [series of rules](topic/automatic-merging.md)
that ensure students never get merge conflicts nor lose any of their changes.

## Create an `nbgitpuller` link via a web extension
Expand Down
10 changes: 6 additions & 4 deletions nbgitpuller/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ async def get(self):
repo = self.get_argument('repo')
branch = self.get_argument('branch', None)
depth = self.get_argument('depth', None)
backup = self.get_argument('backup', 'false')
if depth:
depth = int(depth)
# The default working directory is the directory from which Jupyter
Expand All @@ -84,7 +85,7 @@ async def get(self):
self.set_header('content-type', 'text/event-stream')
self.set_header('cache-control', 'no-cache')

gp = GitPuller(repo, repo_dir, branch=branch, depth=depth, parent=self.settings['nbapp'])
gp = GitPuller(repo, repo_dir, branch=branch, depth=depth, backup=backup, parent=self.settings['nbapp'])

q = Queue()

Expand Down Expand Up @@ -159,11 +160,12 @@ async def get(self):
# working directory) and we end up with weird failures
targetpath = self.get_argument('targetpath', None) or \
self.get_argument('targetPath', repo.rstrip('/').split('/')[-1])
backup = self.get_argument('backup', None)

if urlPath:
path = urlPath
path = urlPath if backup is None else (os.path.join(parent_reldir) if backup == 'true' else None)
else:
path = os.path.join(parent_reldir, targetpath, subPath)
path = os.path.join(parent_reldir, targetpath, subPath) if backup is None else (os.path.join(parent_reldir) if backup == 'true' else None)
if app.lower() == 'lab':
path = 'lab/tree/' + path
elif path.lower().endswith('.ipynb'):
Expand All @@ -173,7 +175,7 @@ async def get(self):

self.write(
jinja_env.get_template('status.html').render(
repo=repo, branch=branch, path=path, depth=depth, targetpath=targetpath, version=__version__,
repo=repo, branch=branch, path=path, depth=depth, targetpath=targetpath, backup=backup, version=__version__,
**self.template_namespace
)
)
Expand Down
14 changes: 12 additions & 2 deletions nbgitpuller/pull.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,24 @@ def __init__(self, git_url, repo_dir, **kwargs):

self.git_url = git_url
self.branch_name = kwargs.pop("branch")
self.backup = kwargs.pop("backup", 'false')
self.repo_dir = repo_dir

if self.branch_name is None:
self.branch_name = self.resolve_default_branch()
elif not self.branch_exists(self.branch_name):
raise ValueError(f"Branch: {self.branch_name} -- not found in repo: {self.git_url}")

self.repo_dir = repo_dir
if self.backup == 'true' and os.path.exists(self.repo_dir):
timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
self.backup_dir = f"{self.repo_dir}_backup_{timestamp}"
backup = os.rename(self.repo_dir, self.backup_dir)
logging.info('Backed up folder {}'.format(self.backup_dir))

newargs = {k: v for k, v in kwargs.items() if v is not None}
super(GitPuller, self).__init__(**newargs)


def branch_exists(self, branch):
"""
This checks to make sure the branch we are told to access
Expand Down Expand Up @@ -356,12 +364,14 @@ def main():
parser.add_argument('git_url', help='Url of the repo to sync')
parser.add_argument('branch_name', default=None, help='Branch of repo to sync', nargs='?')
parser.add_argument('repo_dir', default='.', help='Path to clone repo under', nargs='?')
parser.add_argument('backup', default='false', help='Whether to backup existing repo_dir if it exists', nargs='?')
args = parser.parse_args()

for line in GitPuller(
args.git_url,
args.repo_dir,
branch=args.branch_name if args.branch_name else None
branch=args.branch_name if args.branch_name else None,
backup=args.backup if args.backup else 'false',
).pull():
print(line)

Expand Down
15 changes: 15 additions & 0 deletions nbgitpuller/static/js/giterror.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export function GitError(gitsync, message) {
const s = message.toLowerCase();
const repo = gitsync.repo;
const branch = gitsync.branch;
const path = gitsync.targetpath;
const url = new URL(window.location.href );
url.searchParams.append("backup", "true");

if (s.includes("merge"))
return `<p class="lead">Unresolvable conflicts detected while syncing</p><p><strong>Backup and resync</strong> to backup the current state of your repository and sync updates into a new separate folder:<ul>
<li><code>${path}_backup_YYYYMMDDHHMMSS</code> Timestamped backup folder containing the current state of your repository</li>
<li><code>${path}</code> New folder containing updated content. <em>This new folder will not merge content from your backup due to the unresolvable conflicts.</em> You may want to manually copy backed up changes into the new folder.</li>
</ul></p><p><strong>Proceed without syncing</strong> to continue with the current state of your repository without any new updates.</p><p><a href="https://nbgitpuller.readthedocs.io/en/latest/topic/automatic-merging.html">See more about automatic merging behavior.</a></p>
<a href=${url} class="btn btn-primary role="button" style="margin-right: 5px" aria-label="Backup and resync, then go to Jupyter server.">Backup and resync</a>`;
}
8 changes: 7 additions & 1 deletion nbgitpuller/static/js/gitsync.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
export class GitSync {
constructor(baseUrl, repo, branch, depth, targetpath, path, xsrf) {
constructor(baseUrl, repo, branch, depth, targetpath, path, backup, xsrf) {
// Class that talks to the API backend & emits events as appropriate
this.baseUrl = baseUrl;
this.repo = repo;
this.branch = branch;
this.depth = depth;
this.targetpath = targetpath;
this.redirectUrl = baseUrl + path;
this.backup = backup;
this._xsrf = xsrf;

this.callbacks = {};
Expand Down Expand Up @@ -41,8 +42,13 @@ export class GitSync {
if (typeof this.branch !== 'undefined' && this.branch != undefined) {
syncUrlParams.append('branch', this.branch);
}
if (typeof this.backup !== 'undefined' && this.backup != undefined) {
syncUrlParams.append('backup', this.backup);
}
const syncUrl = this.baseUrl + 'git-pull/api?' + syncUrlParams.toString();

console.log('Starting git sync with URL: ' + syncUrl);

this.eventSource = new EventSource(syncUrl);
this.eventSource.addEventListener('message', (ev) => {
const data = JSON.parse(ev.data);
Expand Down
29 changes: 19 additions & 10 deletions nbgitpuller/static/js/gitsyncview.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { WebLinksAddon } from 'xterm-addon-web-links';
import { GitError } from './giterror';

export class GitSyncView{
constructor(termSelector, progressSelector, termToggleSelector, containerErrorSelector, copyErrorSelector) {
constructor(termSelector, progressSelector, termToggleSelector, containerErrorSelector, copyErrorSelector, containerErrorHelpSelector, recoveryLink) {
// Class that encapsulates view rendering as much as possible
this.term = new Terminal({
convertEol: true
Expand All @@ -20,6 +21,8 @@ export class GitSyncView{
this.termElement = document.querySelector(termSelector);
this.containerError = document.querySelector(containerErrorSelector);
this.copyError = document.querySelector(copyErrorSelector);
this.containerErrorHelp = document.querySelector(containerErrorHelpSelector);
this.recoveryLink = document.querySelector(recoveryLink),

this.termToggle.onclick = () => this.setTerminalVisibility(!this.visible)
}
Expand Down Expand Up @@ -65,17 +68,23 @@ export class GitSyncView{
}
}

setContainerError(isError, errorText='') {
setContainerError(isError, gitsync, errorOutput='', errorMessage='') {
if (isError) {
this.containerError.classList.toggle('hidden', !this.visible);
}
const button = this.copyError;
button.onclick = async () => {
try {
await navigator.clipboard.writeText(errorText);
button.innerHTML = 'Error message copied!';
} catch (err) {
console.error('Failed to copy error text: ', err);
const button = this.copyError;
button.onclick = async () => {
try {
await navigator.clipboard.writeText(errorOutput);
button.innerHTML = 'Error message copied!';
} catch (err) {
console.error('Failed to copy error text: ', err);
}
}
const errorHelp = GitError(gitsync, errorMessage);
if (errorHelp) {
this.containerErrorHelp.innerHTML = errorHelp;
this.termElement.parentElement.classList.add('hidden');
this.containerErrorHelp.appendChild(this.recoveryLink.firstElementChild);
}
}
}
Expand Down
11 changes: 8 additions & 3 deletions nbgitpuller/static/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const gs = new GitSync(
getBodyData('depth'),
getBodyData('targetpath'),
getBodyData('path'),
getBodyData('backup'),
getBodyData('xsrf'),
);

Expand All @@ -30,7 +31,9 @@ const gsv = new GitSyncView(
'#status-panel-title',
'#status-panel-toggle',
'#container-error',
'#copy-error-button',
'#button-copy-error',
"#container-error-help",
"#recovery-link",
);

gs.addHandler('syncing', function(data) {
Expand All @@ -52,8 +55,10 @@ gs.addHandler('error', function(data) {
const errorText= `Repository: ${gs.repo}\nBranch: ${gs.branch}\nRedirect URL: ${gs.redirectUrl}\n\n${data.output}\n`;
gsv.term.write(errorText);
gsv.setContainerError(
true,
errorText
true,
gs,
errorText,
data.message,
);
}
});
Expand Down
Loading