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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ In a distributed environment (Laptop ↔ Desktop), state drift is inevitable.
- Uses a temporary index so it never messes up your partial `git add`.
- Detects if you are rebasing or merging and waits for you to finish.
- Prevents accidental upload of large binaries (configurable threshold).
- Fully compatible with Git linked worktrees and submodules.
- **Cascading Config:** Settings are merged from global defaults, `~/.config/git-pulsar/config.toml`, and local `pulsar.toml` or `pyproject.toml` files.

---
Expand Down
31 changes: 23 additions & 8 deletions src/git_pulsar/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,19 @@ class DoctorAction:
action_callable: Callable[[], bool]


def _get_git_dir(repo_path: Path) -> Path:
"""Resolves the git directory (.git or worktree gitdir)."""
dot_git = repo_path / ".git"
if dot_git.is_dir():
return dot_git
if dot_git.is_file():
try:
return GitRepo(repo_path).git_dir
except Exception:
pass
return dot_git


def _get_ref(repo: GitRepo) -> str:
"""Resolves the namespaced backup reference for the current repository state.

Expand Down Expand Up @@ -123,7 +136,7 @@ def _check_repo_health(path: Path, config: Config) -> str | None:
try:
repo = GitRepo(path)
# Check if the repository is explicitly paused.
if (path / ".git" / "pulsar_paused").exists():
if (_get_git_dir(path) / "pulsar_paused").exists():
return None

# If the working directory is clean, no backup is required.
Expand Down Expand Up @@ -313,7 +326,7 @@ def show_status() -> None:
push_str = "Never"

count = len(repo.status_porcelain())
is_paused = (cwd / ".git" / "pulsar_paused").exists()
is_paused = (_get_git_dir(cwd) / "pulsar_paused").exists()

repo_content = Text()
repo_content.append(f"Last Commit: {commit_str}\n")
Expand Down Expand Up @@ -416,7 +429,7 @@ def list_repos() -> None:
status_text = "Missing"
status_style = "red"
else:
if (path / ".git" / "pulsar_paused").exists():
if (_get_git_dir(path) / "pulsar_paused").exists():
status_text = "Paused"
status_style = "yellow"
else:
Expand Down Expand Up @@ -507,7 +520,7 @@ def _check_git_hooks(repo_path: Path) -> list[str]:
list[str]: A list of warning messages regarding potentially blocking hooks.
"""
warnings: list[str] = []
hooks_dir = repo_path / ".git" / "hooks"
hooks_dir = _get_git_dir(repo_path) / "hooks"

if not hooks_dir.exists():
return warnings
Expand Down Expand Up @@ -692,10 +705,11 @@ def sync_drift() -> bool:
issues = []
for p in paths:
if p.exists():
p_git_dir = _get_git_dir(p)
repo_config = Config.load(p)

# 1a. Check for paused state
pause_file = p / ".git" / "pulsar_paused"
pause_file = p_git_dir / "pulsar_paused"
if pause_file.exists():
issues.append(f"{p.name}: Repository is explicitly paused.")

Expand All @@ -716,7 +730,7 @@ def resume_repo(path_to_unpause: Path = pause_file) -> bool:
)

# 1b. Check for stale index lock
lock_file = p / ".git" / "index.lock"
lock_file = p_git_dir / "index.lock"
if lock_file.exists():
try:
mtime = lock_file.stat().st_mtime
Expand Down Expand Up @@ -886,11 +900,12 @@ def set_pause_state(paused: bool) -> None:
Args:
paused (bool): True to pause backups, False to resume them.
"""
if not Path(".git").exists():
cwd = Path.cwd()
if not (cwd / ".git").exists():
console.print("[bold red]Not a git repository.[/bold red]")
sys.exit(1)

pause_file = Path(".git/pulsar_paused")
pause_file = _get_git_dir(cwd) / "pulsar_paused"
if paused:
pause_file.touch()
console.print(
Expand Down
28 changes: 22 additions & 6 deletions src/git_pulsar/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@
err_console = Console(stderr=True)


def _get_git_dir(repo_path: Path) -> Path:
"""Resolves the git directory (.git or worktree gitdir)."""
dot_git = repo_path / ".git"
if dot_git.is_dir():
return dot_git
if dot_git.is_file():
try:
return GitRepo(repo_path).git_dir
except Exception:
pass
return dot_git


@contextmanager
def temporary_index(repo_path: Path) -> Iterator[dict[str, str]]:
"""Context manager for creating an isolated git index environment.
Expand All @@ -49,7 +62,7 @@ def temporary_index(repo_path: Path) -> Iterator[dict[str, str]]:
Yields:
dict[str, str]: A dictionary containing the modified environment variables.
"""
temp_index = repo_path / ".git" / "pulsar_index"
temp_index = _get_git_dir(repo_path) / "pulsar_index"
env = os.environ.copy()
env["GIT_INDEX_FILE"] = str(temp_index)
try:
Expand Down Expand Up @@ -154,7 +167,7 @@ def is_repo_busy(repo_path: Path, interactive: bool = False) -> bool:
Returns:
bool: True if the repository is busy/locked, False otherwise.
"""
git_dir = repo_path / ".git"
git_dir = _get_git_dir(repo_path)

# 1. Check for operational locks (e.g., MERGE_HEAD).
for f in GIT_LOCK_FILES:
Expand Down Expand Up @@ -242,7 +255,7 @@ def _should_skip(repo_path: Path, config: Config, interactive: bool) -> str | No
if not repo_path.exists():
return "Path missing"

if (repo_path / ".git" / "pulsar_paused").exists():
if (_get_git_dir(repo_path) / "pulsar_paused").exists():
return "Paused by user"

if not interactive:
Expand Down Expand Up @@ -409,7 +422,9 @@ def run_backup(original_path_str: str, interactive: bool = False) -> None:
parents = []
if parent_backup := repo.rev_parse(local_backup_ref):
parents.append(parent_backup)
if parent_head := repo.rev_parse("HEAD"):
if (
parent_head := repo.rev_parse("HEAD")
) and parent_head not in parents:
parents.append(parent_head)

# Check for actual changes
Expand Down Expand Up @@ -438,11 +453,12 @@ def run_backup(original_path_str: str, interactive: bool = False) -> None:

# --- PUSH PHASE ---
if config.daemon.sync_enabled:
current_local_ts = _get_ref_timestamp(repo, local_backup_ref)
local_oid = repo.rev_parse(local_backup_ref)
remote_oid = repo.rev_parse(remote_backup_ref)
last_push_ts = _get_ref_timestamp(repo, remote_backup_ref)

time_since_push = time.time() - last_push_ts
has_new_data = current_local_ts > last_push_ts
has_new_data = bool(local_oid and local_oid != remote_oid)

if has_new_data and (
time_since_push >= config.daemon.push_interval or interactive
Expand Down
15 changes: 14 additions & 1 deletion src/git_pulsar/git_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,19 @@ def _run(
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Git error: {e.stderr or e}") from e

@property
def git_dir(self) -> Path:
"""Resolves the absolute path to the git directory (.git or worktree gitdir)."""
dot_git = self.path / ".git"
if dot_git.is_dir():
return dot_git
try:
res = self._run(["rev-parse", "--git-dir"])
p = Path(res)
return p if p.is_absolute() else (self.path / p).resolve()
except Exception:
return dot_git

def current_branch(self) -> str:
"""Retrieves the name of the currently checked-out branch.

Expand All @@ -87,7 +100,7 @@ def status_porcelain(self, path: str | None = None) -> list[str]:
"""
cmd = ["status", "--porcelain"]
if path:
cmd.append(path)
cmd.extend(["--", path])
output = self._run(cmd)
return output.splitlines() if output else []

Expand Down
19 changes: 16 additions & 3 deletions src/git_pulsar/ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,19 @@ def get_remote_drift_state(repo_path: Path) -> tuple[bool, int, str, str]:
return False, 0, "", ""


def _get_git_dir(repo_path: Path) -> Path:
"""Resolves the git directory (.git or worktree gitdir)."""
dot_git = repo_path / ".git"
if dot_git.is_dir():
return dot_git
if dot_git.is_file():
try:
return GitRepo(repo_path).git_dir
except Exception:
pass
return dot_git


def get_drift_state(repo_path: Path) -> tuple[float, int]:
"""Retrieves the cached state for remote drift detection.

Expand All @@ -128,7 +141,7 @@ def get_drift_state(repo_path: Path) -> tuple[float, int]:
- float: The Unix timestamp of the last time a drift check was performed.
- int: The Unix timestamp of the newest remote session the user was warned about.
"""
state_file = repo_path / ".git" / "pulsar_drift_state"
state_file = _get_git_dir(repo_path) / "pulsar_drift_state"
if not state_file.exists():
return 0.0, 0

Expand Down Expand Up @@ -156,7 +169,7 @@ def set_drift_state(
last_check_ts (float): The Unix timestamp of the current check.
warned_remote_ts (int): The Unix timestamp of the remote session warned about.
"""
state_file = repo_path / ".git" / "pulsar_drift_state"
state_file = _get_git_dir(repo_path) / "pulsar_drift_state"
tmp_file = state_file.with_suffix(".tmp")

data = {
Expand Down Expand Up @@ -437,7 +450,7 @@ def finalize_work() -> None:
"[bold red]CONFLICT:[/bold red] Merge conflicts detected. "
"Please resolve them, then commit."
)
sys.exit(0)
sys.exit(1)

# 8. Interactive Commit.
console.print("-> Committing (opens editor)...")
Expand Down
99 changes: 97 additions & 2 deletions tests/test_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ def test_run_backup_shadow_commit_flow(
# Simulate ref timestamps to ensure Push triggers:
mocker.patch("git_pulsar.daemon._get_ref_timestamp", side_effect=[0, 100, 0])

# Simulate parent resolution (Head exists, Backup doesn't)
repo.rev_parse.side_effect = [None, "head_sha"]
# Simulate parent resolution (Head exists, Backup doesn't) and push resolution
repo.rev_parse.side_effect = [None, "head_sha", "new_backup_sha", None]

daemon.run_backup(str(tmp_path))

Expand Down Expand Up @@ -207,3 +207,98 @@ def test_main_signal_alarm_reset_on_exception(
mocker.call(5),
mocker.call(0),
]


def test_run_backup_deduplicates_identical_parents(
tmp_path: Path, mocker: MagicMock, mock_config: Config
) -> None:
"""Verifies that parents passed to commit_tree contains no duplicates when HEAD == backup."""
(tmp_path / ".git").mkdir()
mocker.patch("git_pulsar.daemon.SYSTEM.is_under_load", return_value=False)
mocker.patch("git_pulsar.daemon.SYSTEM.get_battery", return_value=(100, True))
mocker.patch("git_pulsar.system.get_identity_slug", return_value="test-slug--1234")
mocker.patch("git_pulsar.ops.has_large_files", return_value=False)

mock_cls = mocker.patch("git_pulsar.daemon.GitRepo")
repo = mock_cls.return_value
repo.current_branch.return_value = "main"

# Both backup and HEAD point to "same_sha"
repo.rev_parse.side_effect = ["same_sha", "same_sha", "old_tree"]
repo.write_tree.return_value = "new_tree"
mocker.patch("git_pulsar.daemon._get_ref_timestamp", return_value=0)

daemon.run_backup(str(tmp_path))

# Assert commit_tree was called with parents=["same_sha"] (no duplicate)
repo.commit_tree.assert_called_once()
_, kwargs = repo.commit_tree.call_args
assert kwargs["parents"] == ["same_sha"]


def test_run_backup_push_uses_oid_comparison(
tmp_path: Path, mocker: MagicMock, mock_config: Config
) -> None:
"""Verifies that pushes trigger when local_oid != remote_oid regardless of timestamps."""
(tmp_path / ".git").mkdir()
mocker.patch("git_pulsar.daemon.SYSTEM.is_under_load", return_value=False)
mocker.patch("git_pulsar.daemon.SYSTEM.get_battery", return_value=(100, True))
mocker.patch("git_pulsar.system.get_identity_slug", return_value="test-slug--1234")
mocker.patch("git_pulsar.ops.has_large_files", return_value=False)

mock_cls = mocker.patch("git_pulsar.daemon.GitRepo")
repo = mock_cls.return_value
repo.current_branch.return_value = "main"

# Backup already exists and matches current tree -> skip commit
repo.rev_parse.side_effect = [
"local_sha_123", # local backup ref parse in commit phase
"local_sha_123", # head parse in commit phase
"tree_1", # prev_tree
"local_sha_123", # local backup ref parse in push phase
"remote_sha_456", # remote backup ref parse in push phase
]
repo.write_tree.return_value = "tree_1"

# Both timestamps are 0 (identical)
mocker.patch("git_pulsar.daemon._get_ref_timestamp", return_value=0)

daemon.run_backup(str(tmp_path))

# Verify push was attempted because local_sha_123 != remote_sha_456
repo._run.assert_any_call(
["push", "origin", mocker.ANY], capture=True, env=mocker.ANY
)


def test_temporary_index_and_is_repo_busy_in_worktree(tmp_path: Path) -> None:
"""Verifies that temporary_index and is_repo_busy function correctly in a git worktree."""
import subprocess

main_repo = tmp_path / "main_repo"
main_repo.mkdir()
subprocess.run(["git", "init", "-b", "main"], cwd=main_repo, check=True)
subprocess.run(
["git", "config", "user.name", "Test Runner"], cwd=main_repo, check=True
)
subprocess.run(
["git", "config", "user.email", "test@example.com"], cwd=main_repo, check=True
)
(main_repo / "file.txt").write_text("Hello\n")
subprocess.run(["git", "add", "file.txt"], cwd=main_repo, check=True)
subprocess.run(["git", "commit", "-m", "Initial"], cwd=main_repo, check=True)

worktree = tmp_path / "wt"
subprocess.run(
["git", "worktree", "add", "-b", "wt-branch", str(worktree)],
cwd=main_repo,
check=True,
)

# temporary_index in worktree
with daemon.temporary_index(worktree) as env:
assert "GIT_INDEX_FILE" in env
assert Path(env["GIT_INDEX_FILE"]).parent.exists()

# is_repo_busy in worktree
assert not daemon.is_repo_busy(worktree)
Loading