Skip to content

Commit 590c313

Browse files
clean outputs before commit option
1 parent c61685f commit 590c313

File tree

4 files changed

+169
-0
lines changed

4 files changed

+169
-0
lines changed

jupyterlab_git/git.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1182,6 +1182,56 @@ async def merge(self, branch: str, path: str) -> dict:
11821182
return {"code": code, "command": " ".join(cmd), "message": error}
11831183
return {"code": code, "message": output.strip()}
11841184

1185+
async def check_notebooks_with_outputs(self, path):
1186+
import nbformat, os
1187+
1188+
code, stdout, _ = await self.__execute(
1189+
["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"], cwd=path
1190+
)
1191+
staged_files = stdout.splitlines()
1192+
notebooks = [f for f in staged_files if f.endswith(".ipynb")]
1193+
1194+
dirty_notebooks = []
1195+
1196+
for nb_path in notebooks:
1197+
full_nb_path = os.path.join(path, nb_path)
1198+
try:
1199+
nb = nbformat.read(full_nb_path, as_version=nbformat.NO_CONVERT)
1200+
for cell in nb.get("cells", []):
1201+
if cell.get("cell_type") == "code" and cell.get("outputs"):
1202+
dirty_notebooks.append(nb_path)
1203+
break
1204+
except Exception:
1205+
pass
1206+
1207+
return {
1208+
"notebooks_with_outputs": dirty_notebooks,
1209+
"has_outputs": len(dirty_notebooks) > 0,
1210+
}
1211+
1212+
async def strip_notebook_outputs(self, notebooks: list, repo_path: str):
1213+
for nb_path in notebooks:
1214+
full_path = os.path.join(repo_path, nb_path)
1215+
1216+
try:
1217+
# Clear outputs using nbconvert
1218+
subprocess.run(
1219+
[
1220+
"jupyter",
1221+
"nbconvert",
1222+
"--ClearOutputPreprocessor.enabled=True",
1223+
"--inplace",
1224+
full_path,
1225+
],
1226+
check=True,
1227+
)
1228+
1229+
# Re-stage the cleaned notebook
1230+
subprocess.run(["git", "-C", repo_path, "add", full_path], check=True)
1231+
1232+
except Exception as e:
1233+
print(f"Failed: {nb_path}: {e}")
1234+
11851235
async def commit(self, commit_msg, amend, path, author=None):
11861236
"""
11871237
Execute git commit <filename> command & return the result.

jupyterlab_git/handlers.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,44 @@ async def post(self, path: str = ""):
599599
self.finish(json.dumps(body))
600600

601601

602+
class GitStripNotebooksHandler(GitHandler):
603+
"""Handler to strip outputs from notebooks in a repository."""
604+
605+
@tornado.web.authenticated
606+
async def post(self, path: str = ""):
607+
"""
608+
POST request handler to strip outputs from notebooks.
609+
"""
610+
data = self.get_json_body()
611+
notebooks = data.get("notebooks", [])
612+
613+
try:
614+
await self.git.strip_notebook_outputs(notebooks, self.url2localpath(path))
615+
self.set_status(200)
616+
self.finish(json.dumps({"code": 0, "message": "Notebooks stripped"}))
617+
except Exception as e:
618+
self.set_status(500)
619+
self.finish(
620+
json.dumps(
621+
{
622+
"code": 1,
623+
"message": f"Failed to strip notebook outputs: {str(e)}",
624+
}
625+
)
626+
)
627+
628+
629+
class GitCheckNotebooksHandler(GitHandler):
630+
"""
631+
Handler to check staged notebooks for outputs.
632+
"""
633+
634+
@tornado.web.authenticated
635+
async def get(self, path: str = ""):
636+
body = await self.git.check_notebooks_with_outputs(self.url2localpath(path))
637+
self.finish(json.dumps(body))
638+
639+
602640
class GitUpstreamHandler(GitHandler):
603641
@tornado.web.authenticated
604642
async def post(self, path: str = ""):
@@ -1182,6 +1220,8 @@ def setup_handlers(web_app):
11821220
("/stash_pop", GitStashPopHandler),
11831221
("/stash_apply", GitStashApplyHandler),
11841222
("/submodules", GitSubmodulesHandler),
1223+
("/check_notebooks", GitCheckNotebooksHandler),
1224+
("/strip_notebooks", GitStripNotebooksHandler),
11851225
]
11861226

11871227
handlers = [

src/components/GitPanel.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,9 +803,47 @@ export class GitPanel extends React.Component<IGitPanelProps, IGitPanelState> {
803803
): Promise<void> => {
804804
const errorMsg = this.props.trans.__('Failed to commit changes.');
805805
let id: string | null = notificationId ?? null;
806+
806807
try {
807808
const author = await this._hasIdentity(this.props.model.pathRepository);
808809

810+
const notebooksWithOutputs =
811+
await this.props.model.checkNotebooksForOutputs();
812+
813+
if (notebooksWithOutputs.length > 0) {
814+
const dialog = new Dialog({
815+
title: this.props.trans.__('Notebook outputs detected'),
816+
body: `You are about to commit ${notebooksWithOutputs.length} notebook(s) with outputs.
817+
Would you like to clean them before committing?`,
818+
buttons: [
819+
Dialog.cancelButton({
820+
label: this.props.trans.__('Commit Anyway')
821+
}),
822+
Dialog.okButton({
823+
label: this.props.trans.__('Clean & Commit')
824+
})
825+
]
826+
});
827+
828+
const result = await dialog.launch();
829+
dialog.dispose();
830+
831+
if (result.button.label === this.props.trans.__('Commit Anyway')) {
832+
} else if (
833+
result.button.label === this.props.trans.__('Clean & Commit')
834+
) {
835+
id = Notification.emit(
836+
this.props.trans.__('Cleaning notebook outputs…'),
837+
'in-progress',
838+
{ autoClose: false }
839+
);
840+
841+
await this.props.model.stripNotebooksOutputs(notebooksWithOutputs);
842+
} else {
843+
return;
844+
}
845+
}
846+
809847
const notificationMsg = this.props.trans.__('Committing changes...');
810848
if (id !== null) {
811849
Notification.update({

src/model.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,47 @@ export class GitExtension implements IGitExtension {
733733
await this.refresh();
734734
}
735735

736+
/**
737+
* Check staged notebooks for outputs.
738+
*
739+
* @returns A promise resolving to an array of notebook paths that have outputs
740+
*
741+
* @throws {Git.NotInRepository} If the current path is not a Git repository
742+
* @throws {Git.GitResponseError} If the server response is not ok
743+
* @throws {ServerConnection.NetworkError} If the request cannot be made
744+
*/
745+
async checkNotebooksForOutputs(): Promise<string[]> {
746+
const path = await this._getPathRepository();
747+
748+
return this._taskHandler.execute('git:check-notebooks', async () => {
749+
const result = await requestAPI<{ notebooks_with_outputs: string[] }>(
750+
URLExt.join(path, 'check_notebooks')
751+
);
752+
return result.notebooks_with_outputs;
753+
});
754+
}
755+
756+
/**
757+
* Strip outputs from the given staged notebooks.
758+
*
759+
* @param notebooks - Array of notebook paths to clean
760+
*
761+
* @returns A promise resolving when the operation completes
762+
*
763+
* @throws {Git.NotInRepository} If the current path is not a Git repository
764+
* @throws {Git.GitResponseError} If the server response is not ok
765+
* @throws {ServerConnection.NetworkError} If the request cannot be made
766+
*/
767+
async stripNotebooksOutputs(notebooks: string[]): Promise<void> {
768+
const path = await this._getPathRepository();
769+
770+
await this._taskHandler.execute('git:strip-notebooks', async () => {
771+
await requestAPI(URLExt.join(path, 'strip_notebooks'), 'POST', {
772+
notebooks
773+
});
774+
});
775+
}
776+
736777
/**
737778
* Get (or set) Git configuration options.
738779
*

0 commit comments

Comments
 (0)