Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
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
13 changes: 13 additions & 0 deletions jupyterlab_git/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,19 @@ class JupyterLabGit(Configurable):
config=True,
)

output_cleaning_command = Unicode(
"jupyter nbconvert",
help="Notebook cleaning command. Configurable by server admin.",
config=True,
)

# Extra options to pass to the cleaning tool
output_cleaning_options = Unicode(
"--ClearOutputPreprocessor.enabled=True --inplace",
help="Extra command-line options to pass to the cleaning tool.",
config=True,
)

@default("credential_helper")
def _credential_helper_default(self):
return "cache --timeout=3600"
Expand Down
46 changes: 46 additions & 0 deletions jupyterlab_git/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -1182,6 +1182,52 @@ async def merge(self, branch: str, path: str) -> dict:
return {"code": code, "command": " ".join(cmd), "message": error}
return {"code": code, "message": output.strip()}

async def check_notebooks_with_outputs(self, path):
import nbformat, os

code, stdout, _ = await self.__execute(
["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"], cwd=path
)
staged_files = stdout.splitlines()
notebooks = [f for f in staged_files if f.endswith(".ipynb")]

dirty_notebooks = []

for nb_path in notebooks:
full_nb_path = os.path.join(path, nb_path)
try:
nb = nbformat.read(full_nb_path, as_version=nbformat.NO_CONVERT)
for cell in nb.get("cells", []):
if cell.get("cell_type") == "code" and cell.get("outputs"):
dirty_notebooks.append(nb_path)
break
except Exception:
pass

return {
"notebooks_with_outputs": dirty_notebooks,
"has_outputs": len(dirty_notebooks) > 0,
}

async def strip_notebook_outputs(self, notebooks: list, repo_path: str):
for nb_path in notebooks:
full_path = os.path.join(repo_path, nb_path)

try:
cmd = shlex.split(self._config.output_cleaning_command)
options = shlex.split(self._config.output_cleaning_options)

full_cmd = cmd + options + [full_path]

# Run the cleaning command
subprocess.run(full_cmd, check=True)

# Re-stage the cleaned notebook
subprocess.run(["git", "-C", repo_path, "add", full_path], check=True)

except Exception as e:
print(f"Failed: {nb_path}: {e}")

async def commit(self, commit_msg, amend, path, author=None):
"""
Execute git commit <filename> command & return the result.
Expand Down
40 changes: 40 additions & 0 deletions jupyterlab_git/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,44 @@ async def post(self, path: str = ""):
self.finish(json.dumps(body))


class GitStripNotebooksHandler(GitHandler):
"""Handler to strip outputs from notebooks in a repository."""

@tornado.web.authenticated
async def post(self, path: str = ""):
"""
POST request handler to strip outputs from notebooks.
"""
data = self.get_json_body()
notebooks = data.get("notebooks", [])

try:
await self.git.strip_notebook_outputs(notebooks, self.url2localpath(path))
self.set_status(200)
self.finish(json.dumps({"code": 0, "message": "Notebooks stripped"}))
except Exception as e:
self.set_status(500)
self.finish(
json.dumps(
{
"code": 1,
"message": f"Failed to strip notebook outputs: {str(e)}",
}
)
)


class GitCheckNotebooksHandler(GitHandler):
"""
Handler to check staged notebooks for outputs.
"""

@tornado.web.authenticated
async def get(self, path: str = ""):
body = await self.git.check_notebooks_with_outputs(self.url2localpath(path))
self.finish(json.dumps(body))


class GitUpstreamHandler(GitHandler):
@tornado.web.authenticated
async def post(self, path: str = ""):
Expand Down Expand Up @@ -1182,6 +1220,8 @@ def setup_handlers(web_app):
("/stash_pop", GitStashPopHandler),
("/stash_apply", GitStashApplyHandler),
("/submodules", GitSubmodulesHandler),
("/check_notebooks", GitCheckNotebooksHandler),
("/strip_notebooks", GitStripNotebooksHandler),
]

handlers = [
Expand Down
7 changes: 7 additions & 0 deletions schema/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@
"title": "Hide hidden file warning",
"description": "If true, the warning popup when opening the .gitignore file without hidden files will not be displayed.",
"default": false
},
"clearOutputsBeforeCommit": {
"type": "boolean",
"title": "Clear outputs before commit",
"description": "If true, notebook outputs will be cleared before committing. If false, outputs are kept. If null, ask each time.",
"default": null,
"nullable": true
}
},
"jupyter.lab.shortcuts": [
Expand Down
67 changes: 67 additions & 0 deletions src/components/GitPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { HistorySideBar } from './HistorySideBar';
import { RebaseAction } from './RebaseAction';
import { Toolbar } from './Toolbar';
import { WarningBox } from './WarningBox';
import { Widget } from '@lumino/widgets';

/**
* Interface describing component properties.
Expand Down Expand Up @@ -803,9 +804,75 @@ export class GitPanel extends React.Component<IGitPanelProps, IGitPanelState> {
): Promise<void> => {
const errorMsg = this.props.trans.__('Failed to commit changes.');
let id: string | null = notificationId ?? null;

try {
const author = await this._hasIdentity(this.props.model.pathRepository);

const notebooksWithOutputs =
await this.props.model.checkNotebooksForOutputs();

const clearSetting =
this.props.settings.composite['clearOutputsBeforeCommit'];
if (
notebooksWithOutputs.length > 0 &&
(clearSetting === null || clearSetting === undefined)
) {
const bodyWidget = new Widget();
bodyWidget.node.innerHTML = `
<div>
<p>Clear all outputs before committing?</p>
<label>
<input type="checkbox" id="dontAskAgain" /> Save my preference
</label>
</div>
`;

const dialog = new Dialog({
title: this.props.trans.__('Notebook outputs detected'),
body: bodyWidget,
buttons: [
Dialog.cancelButton({
label: this.props.trans.__('Keep Outputs & Commit')
}),
Dialog.okButton({ label: this.props.trans.__('Clean & Commit') })
],
defaultButton: 0
});

const result = await dialog.launch();
dialog.dispose();

if (result.button.label === this.props.trans.__('Cancel')) {
return;
}
const accepted =
result.button.label === this.props.trans.__('Clean & Commit');
const checkbox =
bodyWidget.node.querySelector<HTMLInputElement>('#dontAskAgain');

// Remember the user’s choice if checkbox is checked
if (checkbox?.checked) {
this.props.settings.set('clearOutputsBeforeCommit', accepted);
}
if (accepted) {
id = Notification.emit(
this.props.trans.__('Cleaning notebook outputs…'),
'in-progress',
{ autoClose: false }
);

await this.props.model.stripNotebooksOutputs(notebooksWithOutputs);
}
} else if (clearSetting === true) {
// Always clean before commit
id = Notification.emit(
this.props.trans.__('Cleaning notebook outputs…'),
'in-progress',
{ autoClose: false }
);
await this.props.model.stripNotebooksOutputs(notebooksWithOutputs);
}

const notificationMsg = this.props.trans.__('Committing changes...');
if (id !== null) {
Notification.update({
Expand Down
41 changes: 41 additions & 0 deletions src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,47 @@
await this.refresh();
}

/**
* Check staged notebooks for outputs.
*
* @returns A promise resolving to an array of notebook paths that have outputs
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async checkNotebooksForOutputs(): Promise<string[]> {
const path = await this._getPathRepository();

return this._taskHandler.execute('git:check-notebooks', async () => {
const result = await requestAPI<{ notebooks_with_outputs: string[] }>(
URLExt.join(path, 'check_notebooks')
);
return result.notebooks_with_outputs;
});
}

/**
* Strip outputs from the given staged notebooks.
*
* @param notebooks - Array of notebook paths to clean
*
* @returns A promise resolving when the operation completes
*
* @throws {Git.NotInRepository} If the current path is not a Git repository
* @throws {Git.GitResponseError} If the server response is not ok
* @throws {ServerConnection.NetworkError} If the request cannot be made
*/
async stripNotebooksOutputs(notebooks: string[]): Promise<void> {
const path = await this._getPathRepository();

await this._taskHandler.execute('git:strip-notebooks', async () => {
await requestAPI(URLExt.join(path, 'strip_notebooks'), 'POST', {
notebooks
});
});
}

/**
* Get (or set) Git configuration options.
*
Expand Down Expand Up @@ -1042,7 +1083,7 @@
follow_path: this.selectedHistoryFile?.to
}
);
} catch (error) {

Check warning on line 1086 in src/model.ts

View workflow job for this annotation

GitHub Actions / Test Python 3.13

'error' is defined but never used

Check warning on line 1086 in src/model.ts

View workflow job for this annotation

GitHub Actions / Test Python 3.9

'error' is defined but never used

Check warning on line 1086 in src/model.ts

View workflow job for this annotation

GitHub Actions / Test Python 3.10

'error' is defined but never used
return { code: 1 };
}
}
Expand Down Expand Up @@ -2007,7 +2048,7 @@

const newSubmodules = data.submodules;
this._submodules = newSubmodules;
} catch (error) {

Check warning on line 2051 in src/model.ts

View workflow job for this annotation

GitHub Actions / Test Python 3.13

'error' is defined but never used

Check warning on line 2051 in src/model.ts

View workflow job for this annotation

GitHub Actions / Test Python 3.9

'error' is defined but never used

Check warning on line 2051 in src/model.ts

View workflow job for this annotation

GitHub Actions / Test Python 3.10

'error' is defined but never used
console.error('Failed to retrieve submodules');
}
}
Expand Down
Loading