add blog entry
This commit is contained in:
+83
-1
@@ -7,6 +7,7 @@ from dataclasses import dataclass, replace
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
@@ -66,6 +67,13 @@ class RepositoryState:
|
||||
def can_push(self) -> bool:
|
||||
return self.sync_state == LOCAL_AHEAD
|
||||
|
||||
@property
|
||||
def can_publish(self) -> bool:
|
||||
return (
|
||||
self.sync_state in {SYNCED, LOCAL_AHEAD}
|
||||
and (self.dirty_count > 0 or bool(self.ahead))
|
||||
)
|
||||
|
||||
|
||||
class GitRepository:
|
||||
"""Narrow wrapper around the installed Git CLI for one working tree."""
|
||||
@@ -156,6 +164,63 @@ class GitRepository:
|
||||
raise GitRepositoryError(f"Push failed: {self._safe_detail(result)}")
|
||||
return self.refresh()
|
||||
|
||||
def publishing_changes(self) -> str:
|
||||
"""Return every path that a publish operation would stage."""
|
||||
result = self._git(
|
||||
"status",
|
||||
"--short",
|
||||
"--untracked-files=all",
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.rstrip()
|
||||
|
||||
def publish(
|
||||
self,
|
||||
expected: RepositoryState,
|
||||
commit_message: str | None,
|
||||
expected_changes: str,
|
||||
) -> RepositoryState:
|
||||
"""Commit reviewed working-tree changes, then perform the guarded push."""
|
||||
current = self.refresh()
|
||||
self._verify_action_state(expected, current)
|
||||
current_changes = self.publishing_changes()
|
||||
if current_changes != expected_changes:
|
||||
raise GitRepositoryError(
|
||||
"The working-tree changes changed after confirmation. Review the "
|
||||
"refreshed list before publishing."
|
||||
)
|
||||
if current.sync_state == DIVERGED:
|
||||
raise GitRepositoryError(self._not_actionable_message(current, "publish"))
|
||||
if current.sync_state == REMOTE_AHEAD:
|
||||
raise GitRepositoryError(
|
||||
"The remote has newer commits. Update the checkout before publishing."
|
||||
)
|
||||
if current.problem:
|
||||
raise GitRepositoryError(current.problem)
|
||||
|
||||
if current.dirty_count:
|
||||
message = " ".join((commit_message or "").splitlines()).strip()
|
||||
if not message:
|
||||
raise GitRepositoryError(
|
||||
"A non-empty commit message is required to publish working-tree changes."
|
||||
)
|
||||
add_result = self._git("add", "--all", check=False)
|
||||
if add_result.returncode != 0:
|
||||
raise GitRepositoryError(
|
||||
f"Could not stage the reviewed changes: {self._safe_detail(add_result)}"
|
||||
)
|
||||
commit_result = self._git("commit", "-m", message, check=False, timeout=60)
|
||||
if commit_result.returncode != 0:
|
||||
raise GitRepositoryError(
|
||||
"Could not create the publishing commit. The changes remain safe "
|
||||
f"locally and may be staged: {self._safe_detail(commit_result)}"
|
||||
)
|
||||
current = self.refresh()
|
||||
|
||||
if current.sync_state != LOCAL_AHEAD:
|
||||
raise GitRepositoryError(self._not_actionable_message(current, "publish"))
|
||||
return self.push(current)
|
||||
|
||||
def _read_state(self, *, compare_remote: bool) -> RepositoryState:
|
||||
self._verify_root()
|
||||
branch_result = self._git(
|
||||
@@ -384,7 +449,11 @@ def _run_git(
|
||||
check: bool,
|
||||
timeout: int = 15,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
command = ["git", *args]
|
||||
executable, bundled = _git_executable(cwd)
|
||||
command = [str(executable)]
|
||||
if bundled:
|
||||
command.extend(("-c", "credential.helper=manager"))
|
||||
command.extend(args)
|
||||
options: dict[str, object] = {
|
||||
"cwd": cwd,
|
||||
"capture_output": True,
|
||||
@@ -410,6 +479,19 @@ def _run_git(
|
||||
return result
|
||||
|
||||
|
||||
def _git_executable(start: Path) -> tuple[Path | str, bool]:
|
||||
"""Use the user's Git when available, with bundled PortableGit as fallback."""
|
||||
system_git = shutil.which("git")
|
||||
if system_git:
|
||||
return system_git, False
|
||||
bundled = start.resolve() / "support" / "PortableGit" / "cmd" / "git.exe"
|
||||
if bundled.is_file():
|
||||
return bundled, True
|
||||
raise GitRepositoryError(
|
||||
"Git is unavailable. Place PortableGit under support\\PortableGit or install Git."
|
||||
)
|
||||
|
||||
|
||||
def _error_detail(result: subprocess.CompletedProcess[str]) -> str:
|
||||
return (result.stderr.strip() or result.stdout.strip() or "unknown Git error").replace(
|
||||
"\n", " "
|
||||
|
||||
Reference in New Issue
Block a user