Skip to content

Commit edd146e

Browse files
committed
fix: roll back staged rename when a step fails
Wrap the staging and commit in Page.rename so a failure after the rename has been staged (e.g. during backlink rewriting) resets the repository to HEAD instead of leaving dangling uncommitted changes.
1 parent 5b347bd commit edd146e

3 files changed

Lines changed: 104 additions & 35 deletions

File tree

otterwiki/gitstorage.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,18 @@ def commit(self, filenames, message="", author=("", ""), no_add=False):
422422
if repo_manager:
423423
repo_manager.auto_push_if_enabled()
424424

425+
def reset(self, revision="HEAD"):
426+
"""Discard staged and working-tree changes, restoring the repository
427+
to `revision` (HEAD by default). Used to roll back a partially
428+
applied, uncommitted operation so the repository is never left with
429+
dangling changes.
430+
"""
431+
self._validate_revision(revision)
432+
try:
433+
self.repo.git.reset("--hard", revision)
434+
except git.exc.GitCommandError as e:
435+
raise StorageError("Reset to {} failed: {}.".format(revision, e))
436+
425437
def revert(self, revision, message="", author=("", "")):
426438
self._validate_revision(revision)
427439
actor = git.Actor(author[0], author[1])

otterwiki/wiki.py

Lines changed: 44 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,42 +1008,51 @@ def rename(self, new_pagename, message, author, update_backlinks):
10081008
# (e.g. the target already exists), and we must not rewrite backlinks
10091009
# in other pages before the rename itself has succeeded - otherwise a
10101010
# failure would leave those rewrites staged but uncommitted.
1011-
changed_files = []
1012-
if (len(files) + len(directories)) > 0:
1013-
# rename attachment directory
1014-
new_attachment_directoryname = get_attachment_directoryname(
1015-
new_filename
1016-
)
1017-
storage.rename(
1018-
self.attachment_directoryname,
1019-
new_attachment_directoryname,
1020-
author=author,
1021-
message=message,
1022-
no_commit=True,
1023-
)
1024-
changed_files.append(new_attachment_directoryname)
1025-
# rename page
1026-
if self.exists:
1027-
storage.rename(
1028-
self.filename,
1029-
new_filename,
1030-
message=message,
1031-
author=author,
1032-
no_commit=True,
1011+
#
1012+
# Everything up to and including the commit is staged in the working
1013+
# tree; if any step raises we roll the repository back to HEAD so a
1014+
# partially applied rename never leaves dangling changes behind.
1015+
try:
1016+
changed_files = []
1017+
if (len(files) + len(directories)) > 0:
1018+
# rename attachment directory
1019+
new_attachment_directoryname = get_attachment_directoryname(
1020+
new_filename
1021+
)
1022+
storage.rename(
1023+
self.attachment_directoryname,
1024+
new_attachment_directoryname,
1025+
author=author,
1026+
message=message,
1027+
no_commit=True,
1028+
)
1029+
changed_files.append(new_attachment_directoryname)
1030+
# rename page
1031+
if self.exists:
1032+
storage.rename(
1033+
self.filename,
1034+
new_filename,
1035+
message=message,
1036+
author=author,
1037+
no_commit=True,
1038+
)
1039+
changed_files.append(new_filename)
1040+
1041+
# Now that the rename has succeeded, rewrite backlinks in other
1042+
# pages.
1043+
pages_updated = (
1044+
rename_backlinks(self.filename, new_pagename)
1045+
if update_backlinks
1046+
else {}
10331047
)
1034-
changed_files.append(new_filename)
1035-
1036-
# Now that the rename has succeeded, rewrite backlinks in other pages.
1037-
pages_updated = (
1038-
rename_backlinks(self.filename, new_pagename)
1039-
if update_backlinks
1040-
else {}
1041-
)
1042-
changed_files += list(pages_updated.keys())
1043-
1044-
# Commit the rename together with the rewritten backlinks, so the
1045-
# repository is never left with uncommitted changes.
1046-
storage.commit(changed_files, message, author, no_add=True)
1048+
changed_files += list(pages_updated.keys())
1049+
1050+
# Commit the rename together with the rewritten backlinks, so the
1051+
# repository is never left with uncommitted changes.
1052+
storage.commit(changed_files, message, author, no_add=True)
1053+
except Exception:
1054+
storage.reset()
1055+
raise
10471056

10481057
# notify plugins of backlink pages updated
10491058
for pagepath, content in pages_updated.items():

tests/test_rename_with_updated_links.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,54 @@ def test_rename_leaves_no_uncommitted_changes(test_client):
111111
)
112112

113113

114+
def test_rename_rolls_back_when_backlink_rewrite_fails(
115+
test_client, monkeypatch
116+
):
117+
"""If any step fails after the rename has been staged, the repository
118+
must be rolled back to its previous state - neither the staged rename nor
119+
the partially rewritten backlinks may be left in the working tree."""
120+
storage = test_client.application.storage
121+
save_shortcut(
122+
test_client, "RollbackTarget", "# RollbackTarget\n", "created target"
123+
)
124+
save_shortcut(
125+
test_client,
126+
"RollbackLinker",
127+
"# RollbackLinker\n\n[a link](/RollbackTarget)\n",
128+
"created linker",
129+
)
130+
head_before = _git_show(storage, "rollbacktarget.md")
131+
132+
def boom(*args, **kwargs):
133+
raise RuntimeError("simulated backlink rewrite failure")
134+
135+
monkeypatch.setattr("otterwiki.wiki.rename_backlinks", boom)
136+
137+
# the rename must fail gracefully (handle_rename catches and toasts)
138+
rv = test_client.post(
139+
"/RollbackTarget/rename",
140+
data={
141+
"new_pagename": "RollbackRenamed",
142+
"message": "",
143+
"update_backlinks": "1",
144+
},
145+
follow_redirects=True,
146+
)
147+
assert rv.status_code == 200
148+
149+
# the repository must be clean ...
150+
status = _git_status(storage)
151+
assert status == "", (
152+
"repository left dirty after failed rename; uncommitted changes:\n"
153+
+ status
154+
)
155+
# ... the original page must still exist under its old name ...
156+
assert storage.exists("rollbacktarget.md")
157+
assert not storage.exists("rollbackrenamed.md")
158+
# ... and the backlink must be untouched.
159+
assert _git_show(storage, "rollbacktarget.md") == head_before
160+
161+
114162
def test_rename_updates_percent_encoded_markdown_link(test_client):
115163
"""Markdown links to a page with a space in its name are commonly
116164
written percent-encoded, e.g. [a link](/Target%20Page). Renaming

0 commit comments

Comments
 (0)