diff --git a/README.md b/README.md index 5a0f7fd..5c96ea3 100644 --- a/README.md +++ b/README.md @@ -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. --- diff --git a/src/git_pulsar/cli.py b/src/git_pulsar/cli.py index 9a9bf01..c1e45ae 100644 --- a/src/git_pulsar/cli.py +++ b/src/git_pulsar/cli.py @@ -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. @@ -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. @@ -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") @@ -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: @@ -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 @@ -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.") @@ -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 @@ -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( diff --git a/src/git_pulsar/daemon.py b/src/git_pulsar/daemon.py index eec6c9a..3044f1a 100644 --- a/src/git_pulsar/daemon.py +++ b/src/git_pulsar/daemon.py @@ -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. @@ -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: @@ -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: @@ -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: @@ -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 @@ -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 diff --git a/src/git_pulsar/git_wrapper.py b/src/git_pulsar/git_wrapper.py index a7c0833..11c35de 100644 --- a/src/git_pulsar/git_wrapper.py +++ b/src/git_pulsar/git_wrapper.py @@ -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. @@ -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 [] diff --git a/src/git_pulsar/ops.py b/src/git_pulsar/ops.py index 586fab5..3f75f9c 100644 --- a/src/git_pulsar/ops.py +++ b/src/git_pulsar/ops.py @@ -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. @@ -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 @@ -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 = { @@ -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)...") diff --git a/tests/test_daemon.py b/tests/test_daemon.py index fb0d0af..e9ff111 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -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)) @@ -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) diff --git a/tests/test_git_wrapper.py b/tests/test_git_wrapper.py index 7bd61b8..7e7558f 100644 --- a/tests/test_git_wrapper.py +++ b/tests/test_git_wrapper.py @@ -113,3 +113,46 @@ def test_git_plumbing_and_porcelain(tmp_path: Path) -> None: # Test checkout mechanics repo.checkout(commit_sha) assert repo.rev_parse("HEAD") == commit_sha + + +def test_git_dir_resolution_worktree(tmp_path: Path) -> None: + """Verifies that GitRepo.git_dir resolves correctly in standard repos and worktrees.""" + 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 / "README.md").write_text("# Main\n") + subprocess.run(["git", "add", "README.md"], cwd=main_repo, check=True) + subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=main_repo, check=True) + + repo = GitRepo(main_repo) + assert repo.git_dir == (main_repo / ".git").resolve() + + # Create a git worktree + worktree_path = tmp_path / "wt" + subprocess.run( + ["git", "worktree", "add", "-b", "wt-branch", str(worktree_path)], + cwd=main_repo, + check=True, + ) + + wt_repo = GitRepo(worktree_path) + assert wt_repo.git_dir.exists() + assert "worktrees" in str(wt_repo.git_dir) + + +def test_status_porcelain_pathspec_double_dash( + mocker: MagicMock, tmp_path: Path +) -> None: + """Verifies that status_porcelain adds the '--' pathspec separator.""" + (tmp_path / ".git").mkdir() + repo = GitRepo(tmp_path) + mock_run = mocker.patch.object(repo, "_run", return_value="") + + repo.status_porcelain("file.txt") + mock_run.assert_called_once_with(["status", "--porcelain", "--", "file.txt"]) diff --git a/tests/test_ops.py b/tests/test_ops.py index f389441..7187864 100644 --- a/tests/test_ops.py +++ b/tests/test_ops.py @@ -232,6 +232,27 @@ def test_finalize_aborts_on_user_decline(mocker: MagicMock) -> None: repo.merge_squash.assert_not_called() +def test_finalize_aborts_on_merge_conflict(mocker: MagicMock) -> None: + """Verifies that merge conflicts during octopus squash abort with exit code 1.""" + repo = mocker.patch("git_pulsar.ops.GitRepo").return_value + repo.status_porcelain.return_value = [] + repo.current_branch.return_value = "feature-branch" + repo.rev_parse.side_effect = ["sha", None] + repo.diff_shortstat.return_value = (2, 10, 5) + repo.get_last_commit_time.return_value = "2 hours ago" + + mocker.patch("git_pulsar.ops.console") + mocker.patch("git_pulsar.ops.Confirm.ask", return_value=True) + repo.list_refs.return_value = ["ref_A"] + repo.merge_squash.side_effect = RuntimeError("Conflict detected") + + with pytest.raises(SystemExit) as excinfo: + ops.finalize_work() + + assert excinfo.value.code == 1 + repo.commit_interactive.assert_not_called() + + # --- Roaming Radar & State Tests ---