From 809ec92e0e66a2c743f0a60a2cab12d4d8fe461a Mon Sep 17 00:00:00 2001 From: "K. Hodges" Date: Sun, 17 May 2026 13:53:10 -0700 Subject: [PATCH] fix apply patch when user has no git --- QUICKSTART.md | 1 + README.md | 1 + docs/tutorial/01-intro.md | 2 + examples/quickstart-lisp/nightshift.yaml | 1 + nightshift/patches.py | 53 ++++++++++++++++++++++++ tests/test_patches.py | 44 ++++++++++++++++++++ 6 files changed, 102 insertions(+) diff --git a/QUICKSTART.md b/QUICKSTART.md index 01fb210..b95bbfe 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -177,6 +177,7 @@ pipeline: type: patch_apply mode: apply output: patch-apply-output.txt + on_fail: implement - id: test type: command diff --git a/README.md b/README.md index 0c14aa6..bc1d5a7 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,7 @@ pipeline: type: patch_apply mode: apply output: patch-apply-output.txt + on_fail: implement - id: test type: command diff --git a/docs/tutorial/01-intro.md b/docs/tutorial/01-intro.md index 777c65e..57d4d0c 100644 --- a/docs/tutorial/01-intro.md +++ b/docs/tutorial/01-intro.md @@ -167,6 +167,7 @@ In `nightshift.yaml`: type: patch_apply mode: dry_run output: patch-apply-output.txt + on_fail: implement ``` Run one task: @@ -199,6 +200,7 @@ If the dry run looks good, switch to apply mode: type: patch_apply mode: apply output: patch-apply-output.txt + on_fail: implement ``` Run again: diff --git a/examples/quickstart-lisp/nightshift.yaml b/examples/quickstart-lisp/nightshift.yaml index 993a638..618456e 100644 --- a/examples/quickstart-lisp/nightshift.yaml +++ b/examples/quickstart-lisp/nightshift.yaml @@ -67,6 +67,7 @@ pipeline: type: patch_apply mode: apply output: patch-apply-output.txt + on_fail: implement - id: test type: command diff --git a/nightshift/patches.py b/nightshift/patches.py index 4aec5cb..07c56b9 100644 --- a/nightshift/patches.py +++ b/nightshift/patches.py @@ -81,6 +81,8 @@ def validate_patch( for path_text in files: _validate_patch_path(path_text, root, scoped_roots, forbidden_paths) + _validate_hunk_lines(patch) + _validate_file_states(patch, root) return PatchValidationResult(files=tuple(sorted(files)), changed_lines=changed_lines) @@ -174,6 +176,57 @@ def _patch_files(patch: str) -> set[str]: return {path for path in files if path} +def _validate_hunk_lines(patch: str) -> None: + in_hunk = False + for line_number, line in enumerate(patch.splitlines(), start=1): + if line.startswith("diff --git "): + in_hunk = False + continue + if line.startswith("@@"): + in_hunk = True + continue + if not in_hunk: + continue + if line.startswith(("+", "-", " ", "\\")): + continue + raise PipelineError( + "Patch validation failed: malformed hunk line " + f"{line_number}; expected ' ', '+', '-', or '\\'." + ) + + +def _validate_file_states(patch: str, root: Path) -> None: + current_path: str | None = None + current_is_new = False + current_is_deleted = False + + def flush() -> None: + if not current_path: + return + target = root / current_path + if current_is_new and target.exists(): + raise PipelineError( + f"Patch validation failed: patch creates existing file `{current_path}`." + ) + if current_is_deleted and not target.exists(): + raise PipelineError( + f"Patch validation failed: patch deletes missing file `{current_path}`." + ) + + for line in patch.splitlines(): + if line.startswith("diff --git "): + flush() + parts = line.split() + current_path = _strip_prefix(parts[3]) if len(parts) >= 4 else None + current_is_new = False + current_is_deleted = False + elif line.startswith("new file mode "): + current_is_new = True + elif line.startswith("deleted file mode "): + current_is_deleted = True + flush() + + def _changed_line_count(patch: str) -> int: count = 0 for line in patch.splitlines(): diff --git a/tests/test_patches.py b/tests/test_patches.py index 4f09799..e6a24ee 100644 --- a/tests/test_patches.py +++ b/tests/test_patches.py @@ -52,6 +52,50 @@ class PatchTests(unittest.TestCase): with self.assertRaisesRegex(PipelineError, "forbidden path"): validate_patch(patch, root, safety) + def test_validate_patch_rejects_malformed_hunk_line(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "src").mkdir() + safety = SafetyConfig( + require_clean_worktree=False, + scoped_paths=("src",), + allowed_commands=(), + forbidden_commands=(), + ) + patch = """diff --git a/src/app.py b/src/app.py +--- a/src/app.py ++++ b/src/app.py +@@ -1 +1,2 @@ +-old ++new +bare line +""" + + with self.assertRaisesRegex(PipelineError, "malformed hunk line"): + validate_patch(patch, root, safety) + + def test_validate_patch_rejects_new_file_when_target_exists(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "src").mkdir() + (root / "src" / "app.py").write_text("old\n", encoding="utf-8") + safety = SafetyConfig( + require_clean_worktree=False, + scoped_paths=("src",), + allowed_commands=(), + forbidden_commands=(), + ) + patch = """diff --git a/src/app.py b/src/app.py +new file mode 100644 +--- /dev/null ++++ b/src/app.py +@@ -0,0 +1 @@ ++new +""" + + with self.assertRaisesRegex(PipelineError, "creates existing file"): + validate_patch(patch, root, safety) + if __name__ == "__main__": unittest.main()