Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 18 additions & 50 deletions src/git_pulsar/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@
from . import daemon, ops, service, system
from .config import CONFIG_FILE, Config
from .constants import (
APP_LABEL,
DEFAULT_IGNORES,
HOMEBREW_LABEL,
LOG_FILE,
PID_FILE,
REGISTRY_FILE,
Expand All @@ -38,27 +36,6 @@ def _get_ref(repo: GitRepo) -> str:
return ops.get_backup_ref(repo.current_branch())


def _is_service_enabled() -> bool:
"""Checks if the system service is currently loaded and active.

Supports both launchd (macOS) and systemd (Linux).

Returns:
bool: True if the service is active/loaded, False otherwise.
"""
if sys.platform == "darwin":
res = subprocess.run(["launchctl", "list"], capture_output=True, text=True)
return HOMEBREW_LABEL in res.stdout
elif sys.platform.startswith("linux"):
res = subprocess.run(
["systemctl", "--user", "is-active", f"{APP_LABEL}.timer"],
capture_output=True,
text=True,
)
return res.stdout.strip() == "active"
return False


def _analyze_logs(hours: int = 24) -> list[str]:
"""
Scans the daemon log for error messages that occurred within a recent time window.
Expand Down Expand Up @@ -194,7 +171,7 @@ def show_status() -> None:
pid_running = False

# Check if the system service is scheduled/enabled.
service_enabled = _is_service_enabled()
service_enabled = service.is_service_enabled()

if pid_running:
status_text = "Active (Running)"
Expand All @@ -218,11 +195,9 @@ def show_status() -> None:

# Check registration status.
is_registered = False
if REGISTRY_FILE.exists():
with open(REGISTRY_FILE, "r") as f:
registered = {line.strip() for line in f if line.strip()}
if str(cwd) in registered:
is_registered = True
# Use the system helper (returns list[Path])
if cwd in system.get_registered_repos():
is_registered = True

if not is_registered:
console.print(
Expand Down Expand Up @@ -277,8 +252,7 @@ def show_status() -> None:

# Display global repository count if not currently in a repository.
elif REGISTRY_FILE.exists():
with open(REGISTRY_FILE) as f:
count = len([line for line in f if line.strip()])
count = len(system.get_registered_repos())
console.print(f"[dim]Watching {count} repositories.[/dim]")


Expand Down Expand Up @@ -314,11 +288,9 @@ def list_repos() -> None:
table.add_column("Status")
table.add_column("Last Backup", justify="right", style="dim")

with open(REGISTRY_FILE, "r") as f:
lines = [line.strip() for line in f if line.strip()]
repos = system.get_registered_repos()

for path_str in lines:
path = Path(path_str)
for path in repos:
display_path = str(path).replace(str(Path.home()), "~")

status_text = "Unknown"
Expand Down Expand Up @@ -362,19 +334,17 @@ def unregister_repo() -> None:
console.print("Registry is empty.", style="yellow")
return

with open(REGISTRY_FILE, "r") as f:
lines = [line.strip() for line in f if line.strip()]

if cwd not in lines:
current_paths = [str(p) for p in system.get_registered_repos()]
if cwd not in current_paths:
console.print(
f"Current path not registered: [cyan]{cwd}[/cyan]", style="yellow"
)
return

with open(REGISTRY_FILE, "w") as f:
for line in lines:
if line != cwd:
f.write(f"{line}\n")
for path in current_paths:
if path != cwd:
f.write(f"{path}\n")
console.print(f"✔ Unregistered: [cyan]{cwd}[/cyan]", style="green")


Expand All @@ -386,17 +356,15 @@ def run_doctor() -> None:

# Verify and clean the registry.
with console.status("[bold blue]Checking Registry...", spinner="dots"):
if not REGISTRY_FILE.exists():
repos = system.get_registered_repos()
if not repos and not REGISTRY_FILE.exists():
console.print(" [green]✔ Registry empty/clean.[/green]")
else:
with open(REGISTRY_FILE, "r") as f:
lines = [line.strip() for line in f if line.strip()]

valid_lines = []
fixed = False
for line in lines:
if Path(line).exists():
valid_lines.append(line)
for p in repos:
if p.exists():
valid_lines.append(str(p))
else:
fixed = True

Expand All @@ -411,7 +379,7 @@ def run_doctor() -> None:

# Check daemon status.
with console.status("[bold blue]Checking Daemon...", spinner="dots"):
if _is_service_enabled():
if service.is_service_enabled():
console.print(" [green]✔ Daemon is active.[/green]")
else:
console.print(
Expand Down
17 changes: 12 additions & 5 deletions src/git_pulsar/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ class Config:
files: FilesConfig = field(default_factory=FilesConfig)
daemon: DaemonConfig = field(default_factory=DaemonConfig)

# Cache for the base global configuration
_global_cache: "Config | None" = None

@classmethod
def load(cls, repo_path: Path | None = None) -> "Config":
"""Loads and merges configuration from defaults, global, and local sources.
Expand All @@ -110,11 +113,15 @@ def load(cls, repo_path: Path | None = None) -> "Config":
Returns:
Config: The fully merged configuration object.
"""
instance = cls()

# 1. Load Global Config
if CONFIG_FILE.exists():
instance._merge_from_file(CONFIG_FILE)
# 1. Load or Retrieve Global Config
if cls._global_cache is None:
instance = cls()
if CONFIG_FILE.exists():
instance._merge_from_file(CONFIG_FILE)
cls._global_cache = instance

# Start with a copy of the cached global config
instance = replace(cls._global_cache)

# 2. Load Local Config (if applicable)
if repo_path:
Expand Down
43 changes: 25 additions & 18 deletions src/git_pulsar/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,7 @@ def is_repo_busy(repo_path: Path, interactive: bool = False) -> bool:
except OSError:
pass # File vanished (race resolved).

# B. Wait-and-see (Micro-retry) to handle transient operations.
time.sleep(1.0)
# B. Fail fast if lock still exists
if lock_file.exists():
return True

Expand All @@ -205,7 +204,8 @@ def has_large_files(repo_path: Path, config: Config) -> bool:
try:
cmd = ["git", "ls-files", "--others", "--modified", "--exclude-standard"]
candidates = subprocess.check_output(cmd, cwd=repo_path, text=True).splitlines()
except subprocess.CalledProcessError:
except subprocess.CalledProcessError as e:
logger.warning(f"Large file scan failed for {repo_path.name}: {e}")
return False

for name in candidates:
Expand All @@ -221,7 +221,8 @@ def has_large_files(repo_path: Path, config: Config) -> bool:
)
SYSTEM.notify("Backup Aborted", f"File >{limit_mb}MB detected: {name}")
return True
except OSError:
except OSError as e:
logger.warning(f"Failed to check size of file {name}: {e}")
continue

return False
Expand Down Expand Up @@ -361,7 +362,8 @@ def _get_ref_timestamp(repo: GitRepo, ref: str) -> int:
try:
ts = repo._run(["log", "-1", "--format=%ct", ref])
return int(ts.strip())
except Exception:
except Exception as e:
logger.debug(f"Could not get timestamp for {ref}: {e}")
return 0


Expand Down Expand Up @@ -392,15 +394,10 @@ def run_backup(original_path_str: str, interactive: bool = False) -> None:
if not current_branch:
return

slug = system.get_identity_slug()
namespace = config.core.backup_branch

# Define Refs
local_backup_ref = f"refs/heads/{namespace}/{slug}/{current_branch}"
remote_backup_ref = (
f"refs/remotes/{config.core.remote_name}/"
f"{namespace}/{slug}/{current_branch}"
)
local_backup_ref = ops.get_backup_ref(current_branch)
ref_suffix = local_backup_ref.replace("refs/heads/", "")
remote_backup_ref = f"refs/remotes/{config.core.remote_name}/{ref_suffix}"

# --- COMMIT PHASE ---
last_commit_ts = _get_ref_timestamp(repo, local_backup_ref)
Expand All @@ -409,6 +406,10 @@ def run_backup(original_path_str: str, interactive: bool = False) -> None:
if time_since_commit >= config.daemon.commit_interval:
with temporary_index(repo_path) as env:
# Stage current working directory into temp index.
# Use wrapper method if available, or repo._run(["add", "."], env=env)
repo.add_all()
# Note: GitRepo.add_all() in wrapper doesn't accept env.
# Keeping manual run with env.
repo._run(["add", "."], env=env)

# Write Tree.
Expand All @@ -430,9 +431,16 @@ def run_backup(original_path_str: str, interactive: bool = False) -> None:

if should_commit:
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")

# Use wrapper method
commit_oid = repo.commit_tree(
tree_oid, parents, f"Shadow backup {timestamp}", env=env
tree=tree_oid,
parents=parents,
message=f"Shadow backup {timestamp}",
env=env,
)

# Use wrapper method
repo.update_ref(local_backup_ref, commit_oid, parent_backup)

if interactive:
Expand Down Expand Up @@ -492,17 +500,16 @@ def main(interactive: bool = False) -> None:
"""
setup_logging(interactive)

if not REGISTRY_FILE.exists():
repos = [str(p) for p in system.get_registered_repos()]

if not repos:
if interactive:
console.print(
"[yellow]Registry empty. Run 'git-pulsar' in "
"a repo to register it.[/yellow]"
)
return

with open(REGISTRY_FILE, "r") as f:
repos = [line.strip() for line in f if line.strip()]

# Set a timeout handler for stalled network mounts.
def timeout_handler(_signum: int, _frame: FrameType | None) -> None:
raise TimeoutError("Repo access timed out")
Expand Down
23 changes: 19 additions & 4 deletions src/git_pulsar/git_wrapper.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import logging
import subprocess
from pathlib import Path
from typing import Optional

from .constants import APP_NAME

logger = logging.getLogger(APP_NAME)


class GitRepo:
"""A wrapper around the Git command-line interface for a specific repository.
Expand Down Expand Up @@ -164,7 +169,8 @@ def list_refs(self, pattern: str) -> list[str]:
try:
output = self._run(["for-each-ref", "--format=%(refname)", pattern])
return output.splitlines() if output else []
except Exception:
except Exception as e:
logger.warning(f"Git error listing refs for {pattern}: {e}")
return []

def get_last_commit_time(self, branch: str) -> str:
Expand Down Expand Up @@ -193,7 +199,8 @@ def rev_parse(self, rev: str) -> Optional[str]:
"""
try:
return self._run(["rev-parse", rev])
except Exception:
except Exception as e:
logger.debug(f"rev-parse failed for '{rev}': {e}")
return None

def write_tree(self, env: Optional[dict] = None) -> str:
Expand Down Expand Up @@ -226,7 +233,11 @@ def commit_tree(
cmd = ["commit-tree", tree, "-m", message]
for p in parents:
cmd.extend(["-p", p])
return self._run(cmd, env=env)
try:
return self._run(cmd, env=env)
except Exception as e:
logger.warning(f"Failed to commit tree {tree}: {e}")
raise

def update_ref(self, ref: str, new_oid: str, old_oid: Optional[str] = None) -> None:
"""Safely updates a reference to a new object ID.
Expand All @@ -241,7 +252,11 @@ def update_ref(self, ref: str, new_oid: str, old_oid: Optional[str] = None) -> N
cmd = ["update-ref", "-m", "Pulsar backup", ref, new_oid]
if old_oid:
cmd.append(old_oid)
self._run(cmd)
try:
self._run(cmd)
except Exception as e:
logger.warning(f"Failed to update ref {ref}: {e}")
raise

def get_untracked_files(self) -> list[str]:
"""Lists files that are not tracked by git and are not ignored.
Expand Down
Loading