Skip to content

Commit f3b4381

Browse files
committed
allow update when only file mode changed
1 parent fefa99e commit f3b4381

2 files changed

Lines changed: 51 additions & 2 deletions

File tree

src/octopal/cli/main.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,28 @@ def _run_capture(command: list[str], *, cwd: Path, timeout: float = 10.0) -> sub
286286
)
287287

288288

289+
def _list_meaningful_worktree_changes(project_root: Path) -> list[str] | None:
290+
diff = _run_capture(["git", "diff", "--name-only"], cwd=project_root)
291+
if diff.returncode != 0:
292+
return None
293+
294+
changes: list[str] = []
295+
for raw_path in diff.stdout.splitlines():
296+
path = raw_path.strip()
297+
if not path:
298+
continue
299+
300+
stats = _run_capture(["git", "diff", "--numstat", "--", path], cwd=project_root)
301+
if stats.returncode != 0:
302+
return None
303+
304+
# `git diff --numstat` is empty for mode-only changes, so treat those as safe.
305+
if stats.stdout.strip():
306+
changes.append(path)
307+
308+
return changes
309+
310+
289311
def _git_checkout_ready_for_update(project_root: Path) -> tuple[bool, str | None]:
290312
if not (project_root / ".git").exists():
291313
return False, "This install is not a git checkout."
@@ -299,7 +321,14 @@ def _git_checkout_ready_for_update(project_root: Path) -> tuple[bool, str | None
299321
return False, f"Could not inspect git status: {detail}"
300322

301323
if status.stdout.strip():
302-
return False, "Working tree has local changes. Commit or stash them first."
324+
meaningful_changes = _list_meaningful_worktree_changes(project_root)
325+
if meaningful_changes is None:
326+
return False, "Could not determine whether local changes are content changes or mode-only changes."
327+
if meaningful_changes:
328+
names = ", ".join(meaningful_changes[:3])
329+
if len(meaningful_changes) > 3:
330+
names += ", ..."
331+
return False, f"Working tree has local content changes: {names}. Commit or stash them first."
303332

304333
return True, None
305334

tests/test_cli_update.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from __future__ import annotations
22

3+
import subprocess
4+
35
from typer.testing import CliRunner
46

5-
from octopal.cli.main import app
7+
from octopal.cli.main import _git_checkout_ready_for_update, app
68

79
runner = CliRunner()
810

@@ -49,3 +51,21 @@ def test_update_warns_when_runtime_is_active(monkeypatch, tmp_path) -> None:
4951
assert result.exit_code == 0
5052
assert "Octopal is running right now." in result.stdout
5153
assert "uv run octopal restart" in result.stdout
54+
55+
56+
def test_git_checkout_ready_allows_mode_only_changes(monkeypatch, tmp_path) -> None:
57+
(tmp_path / ".git").mkdir()
58+
59+
def fake_run_capture(command: list[str], *, cwd, timeout=10.0):
60+
if command == ["git", "status", "--porcelain"]:
61+
return subprocess.CompletedProcess(command, 0, stdout=" M scripts/bootstrap.sh\n", stderr="")
62+
if command == ["git", "diff", "--name-only"]:
63+
return subprocess.CompletedProcess(command, 0, stdout="scripts/bootstrap.sh\n", stderr="")
64+
if command == ["git", "diff", "--numstat", "--", "scripts/bootstrap.sh"]:
65+
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
66+
raise AssertionError(f"unexpected command: {command}")
67+
68+
monkeypatch.setattr("octopal.cli.main.shutil.which", lambda _name: "/usr/bin/git")
69+
monkeypatch.setattr("octopal.cli.main._run_capture", fake_run_capture)
70+
71+
assert _git_checkout_ready_for_update(tmp_path) == (True, None)

0 commit comments

Comments
 (0)