diff --git a/README.md b/README.md index 9f29e83..19f5f01 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ This bootstraps the current directory with: | `git pulsar sync` | Pull the latest session from *any* machine to your current directory. | | `git pulsar restore ` | Restore a specific file from the latest backup. | | `git pulsar diff` | See what has changed since the last backup. | -| `git pulsar finalize` | Squash-merge all backup streams into `main`. | +| `git pulsar finalize` | Squash-merge all backup streams into `main` (includes pre-flight checklist). | ### Repository Control @@ -245,7 +245,7 @@ ignore = ["*.tmp", "node_modules/"] *Focus: Turning the tool from a blind script into a helpful partner that negotiates with you.* - [x] **Smart Restore:** Replace hard failures on "dirty" files with a negotiation menu (Overwrite / View Diff / Cancel). -- [ ] **Pre-Flight Checklists:** Display a summary table of incoming changes (machines, timestamps, file counts) before running destructive commands like `finalize`. +- [x] **Pre-Flight Checklists:** Display a summary table of incoming changes (machines, timestamps, file counts) before running destructive commands like `finalize`. - [x] **Active Doctor:** Upgrade `git pulsar doctor` to not just diagnose issues (like stopped daemons), but offer to auto-fix them interactively. ### Phase 2: "Deep Thought" (Context & Intelligence) diff --git a/src/README.md b/src/README.md index 28d44de..a8809e6 100644 --- a/src/README.md +++ b/src/README.md @@ -12,7 +12,7 @@ The `src/` directory contains the package source code. The architecture strictly - **Safety:** Implements `GIT_INDEX_FILE` isolation to ensure it never locks or corrupts the user's active git index. - **`git_pulsar/ops.py`**: High-level Business Logic. - **Role:** The "Controller." It orchestrates complex multi-step operations like `finalize` (Octopus Merges), `restore`, and drift detection. - - **Logic:** Calculates the "Zipper Graph" topology to merge shadow commits back into the main branch, manages atomic file I/O for cross-process state tracking, evaluates pipeline blockers (e.g., oversized files), and handles interactive state machines for file restorations. + - **Logic:** Calculates the "Zipper Graph" topology to merge shadow commits back into the main branch , manages the interactive pre-flight checklist prior to finalizing, handles atomic file I/O for cross-process state tracking, evaluates pipeline blockers (e.g., oversized files), and handles interactive state machines for file restorations. - **`git_pulsar/config.py`**: Configuration Engine. - **Role:** The "Source of Truth." - **Logic:** Implements a cascading hierarchy (Defaults → Global → Local) to merge settings from `~/.config/git-pulsar/config.toml` and project-level `pulsar.toml` or `pyproject.toml`. @@ -21,7 +21,7 @@ The `src/` directory contains the package source code. The architecture strictly - **`git_pulsar/git_wrapper.py`**: The Git Interface. - **Role:** A strict wrapper around `subprocess`. - - **Philosophy:** **No Porcelain.** This module primarily uses git *plumbing* commands (`write-tree`, `commit-tree`, `update-ref`) rather than user-facing commands (`commit`, `add`) to ensure deterministic behavior. It also handles dynamic command construction, such as safe boundary markers (`--`) for file-level diff targeting. + - **Philosophy:** **No Porcelain.** This module primarily uses git *plumbing* commands (`write-tree`, `commit-tree`, `update-ref`) rather than user-facing commands (`commit`, `add`) to ensure deterministic behavior. It also handles dynamic command construction, such as safe boundary markers (`--`) for file-level diff targeting, and robust regex parsing for output like `diff --shortstat`. - **`git_pulsar/system.py`**: OS Abstraction. - **Role:** Identity & Environment. - **Logic:** Handles the chaos of cross-platform identity (mapping `IOPlatformUUID` on macOS vs `/etc/machine-id` on Linux) to ensure stable "Roaming Profiles." diff --git a/src/git_pulsar/git_wrapper.py b/src/git_pulsar/git_wrapper.py index 5224762..b596a86 100644 --- a/src/git_pulsar/git_wrapper.py +++ b/src/git_pulsar/git_wrapper.py @@ -1,4 +1,5 @@ import logging +import re import subprocess from pathlib import Path @@ -277,3 +278,36 @@ def run_diff(self, target: str, file: str | None = None) -> None: if file: cmd.extend(["--", file]) self._run(cmd, capture=False) + + def diff_shortstat(self, target: str, source: str) -> tuple[int, int, int]: + """Retrieves the shortstat differences between two references. + + Executes `git diff --shortstat target...source` to determine the + number of files changed, insertions, and deletions present in the + source reference that are not in the target. + + Args: + target (str): The base reference (e.g., 'main'). + source (str): The branch or commit to compare (e.g., a backup ref). + + Returns: + tuple[int, int, int]: A tuple containing (files_changed, insertions, deletions). + Returns (0, 0, 0) if there are no differences or parsing fails. + """ + try: + output = self._run(["diff", "--shortstat", f"{target}...{source}"]) + if not output: + return 0, 0, 0 + + files_match = re.search(r"(\d+)\s+file", output) + insertions_match = re.search(r"(\d+)\s+insertion", output) + deletions_match = re.search(r"(\d+)\s+deletion", output) + + files = int(files_match.group(1)) if files_match else 0 + insertions = int(insertions_match.group(1)) if insertions_match else 0 + deletions = int(deletions_match.group(1)) if deletions_match else 0 + + return files, insertions, deletions + except Exception as e: + logger.warning(f"Failed to parse shortstat for {target}...{source}: {e}") + return 0, 0, 0 diff --git a/src/git_pulsar/ops.py b/src/git_pulsar/ops.py index 618e8f3..e35e8bc 100644 --- a/src/git_pulsar/ops.py +++ b/src/git_pulsar/ops.py @@ -11,7 +11,8 @@ from rich.console import Console from rich.panel import Panel -from rich.prompt import Prompt +from rich.prompt import Confirm, Prompt +from rich.table import Table from . import system from .config import Config @@ -442,7 +443,7 @@ def finalize_work() -> None: This performs an 'Octopus Squash' merge of all backup streams for the current branch into the main/master branch, effectively finalizing the work session - and updating the primary project history. + and updating the primary project history. Includes a pre-flight dry-run checklist. """ console.print("[bold blue]FINALIZING:[/bold blue] Finalizing work...") repo = GitRepo(Path.cwd()) @@ -486,19 +487,45 @@ def finalize_work() -> None: ) sys.exit(1) - console.print(f"-> Found {len(candidates)} backup stream(s):") - for c in candidates: - console.print(f" • {c}") - - # 4. Switch to the target branch (main/master). + # 4. Resolve Target Branch. target = "main" if not repo.rev_parse("main") and repo.rev_parse("master"): target = "master" + # 5. Pre-Flight Checklist. + console.print(f"\n[bold]Pre-Flight Checklist (Target: {target})[/bold]") + table = Table( + show_header=True, header_style="bold magenta", border_style="blue" + ) + table.add_column("Machine", style="cyan") + table.add_column("Last Backup", style="dim") + table.add_column("Files", justify="right") + table.add_column("+", style="green", justify="right") + table.add_column("-", style="red", justify="right") + + for c in candidates: + machine = c.split("/")[-2] if len(c.split("/")) >= 2 else "unknown" + try: + rel_time = repo.get_last_commit_time(c) + except Exception: + rel_time = "Unknown" + + files, ins, dels = repo.diff_shortstat(target, c) + table.add_row(machine, rel_time, str(files), str(ins), str(dels)) + + console.print(table) + + if not Confirm.ask( + f"\nSquash these {len(candidates)} streams into '{target}'?" + ): + console.print("[bold red]ABORTED.[/bold red] Working directory unchanged.") + sys.exit(0) + + # 6. Switch to the target branch (main/master). console.print(f"-> Switching to {target}...") repo.checkout(target) - # 5. Perform Octopus Squash Merge. + # 7. Perform Octopus Squash Merge. with console.status( f"[bold blue]Collapsing {len(candidates)} backup streams...[/bold blue]", spinner="dots", @@ -512,7 +539,7 @@ def finalize_work() -> None: ) sys.exit(0) - # 6. Interactive Commit. + # 8. Interactive Commit. console.print("-> Committing (opens editor)...") repo.commit_interactive() diff --git a/tests/README.md b/tests/README.md index aaa1fb2..f6385c5 100644 --- a/tests/README.md +++ b/tests/README.md @@ -32,6 +32,7 @@ Pulsar relies on stable machine identity to manage distributed sessions. Verifies the "State Reconciliation" engine and primitive operations. - **Octopus Merges:** Simulates complex multi-head merge scenarios (e.g., merging 3 different machine streams into `main`) to ensure the DAG (Directed Acyclic Graph) is constructed correctly without conflicts. +- **Pre-Flight Checklist Negotiation:** Verifies the interactive dry-run table before finalizing, ensuring that declined merges cleanly abort without mutating the active working branch. - **State Management:** Verifies atomic file I/O operations (`set_drift_state`) to ensure cross-process thread safety between the background daemon and foreground CLI. - **Drift Detection:** Tests the core logic for identifying when remote sessions leapfrog local ones, simulating various network failures and detached HEAD states. - **Pipeline Blockers:** Validates decoupled checks for oversized files (`has_large_files`), ensuring they safely abort operations and trigger system notifications without polluting the daemon's event loop. @@ -59,6 +60,7 @@ Validates the state-aware diagnostic engine and user-facing CLI commands. Ensures the Python-to-Git subprocess boundary remains secure and predictable. - **Command Construction:** Verifies that dynamic arguments—such as file-level diff targeting—correctly append necessary boundary markers (`--`) to prevent Git from misinterpreting file paths as revision hashes. +- **Regex Parsing Determinism:** Validates the extraction of insertions, deletions, and changed files from variable `git diff --shortstat` output, ensuring the data pipeline doesn't break when Git omits empty clauses. - **Error Handling:** Ensures low-level subprocess failures are caught and logged rather than causing silent upstream crashes. --- diff --git a/tests/test_git_wrapper.py b/tests/test_git_wrapper.py index 37a5dc0..c3f9e03 100644 --- a/tests/test_git_wrapper.py +++ b/tests/test_git_wrapper.py @@ -40,3 +40,26 @@ def test_run_diff_with_file_targeting(mocker: MagicMock, tmp_path: Path) -> None mock_run.assert_called_with( ["diff", "refs/backup/main", "--", "src/main.py"], capture=False ) + + +def test_diff_shortstat_regex_parsing(mocker: MagicMock, tmp_path: Path) -> None: + """Verifies that shortstat parses correctly, handling missing clauses.""" + (tmp_path / ".git").mkdir() + repo = GitRepo(tmp_path) + mock_run = mocker.patch.object(repo, "_run") + + # Case 1: Standard output with all three metrics + mock_run.return_value = " 3 files changed, 25 insertions(+), 4 deletions(-)" + assert repo.diff_shortstat("main", "backup_ref") == (3, 25, 4) + + # Case 2: Missing deletions clause + mock_run.return_value = " 1 file changed, 10 insertions(+)" + assert repo.diff_shortstat("main", "backup_ref") == (1, 10, 0) + + # Case 3: Missing insertions clause + mock_run.return_value = " 2 files changed, 12 deletions(-)" + assert repo.diff_shortstat("main", "backup_ref") == (2, 0, 12) + + # Case 4: Empty diff (branch is up to date) + mock_run.return_value = "" + assert repo.diff_shortstat("main", "backup_ref") == (0, 0, 0) diff --git a/tests/test_ops.py b/tests/test_ops.py index 6a5b7eb..f2eb289 100644 --- a/tests/test_ops.py +++ b/tests/test_ops.py @@ -236,31 +236,28 @@ def mock_run(cmd: list[str], *args: Any, **kwargs: Any) -> str: def test_finalize_octopus_merge(mocker: MagicMock) -> None: - """Verifies that `finalize_work` performs an octopus squash merge of backup streams. - - Args: - mocker (MagicMock): Pytest fixture for mocking. - """ + """Verifies that `finalize_work` performs an octopus squash merge of backup streams.""" repo = mocker.patch("git_pulsar.ops.GitRepo").return_value repo.status_porcelain.return_value = [] - repo.current_branch.return_value = "main" + repo.current_branch.return_value = "feature-branch" + repo.rev_parse.side_effect = ["sha", None] # main exists, master doesn't + repo.diff_shortstat.return_value = (2, 10, 5) + + # Provide a string so rich doesn't panic + repo.get_last_commit_time.return_value = "2 hours ago" mocker.patch("git_pulsar.ops.console") + # Mock the new pre-flight confirmation to proceed + mocker.patch("git_pulsar.ops.Confirm.ask", return_value=True) + # Simulate finding 3 backup streams. repo.list_refs.return_value = ["ref_A", "ref_B", "ref_C"] ops.finalize_work() - # 1. Verify Fetch. - repo._run.assert_any_call( - [ - "fetch", - "origin", - f"refs/heads/{BACKUP_NAMESPACE}/*:refs/heads/{BACKUP_NAMESPACE}/*", - ], - capture=True, - ) + # 1. Verify target branch switch happened + repo.checkout.assert_called_with("main") # 2. Verify Octopus Merge of all streams. repo.merge_squash.assert_called_with("ref_A", "ref_B", "ref_C") @@ -269,6 +266,33 @@ def test_finalize_octopus_merge(mocker: MagicMock) -> None: repo.commit_interactive.assert_called_once() +def test_finalize_aborts_on_user_decline(mocker: MagicMock) -> None: + """Verifies that declining the pre-flight checklist exits cleanly without checking out.""" + 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) + + # Provide a string so rich doesn't panic + repo.get_last_commit_time.return_value = "2 hours ago" + + mocker.patch("git_pulsar.ops.console") + + # Mock the pre-flight confirmation to abort + mocker.patch("git_pulsar.ops.Confirm.ask", return_value=False) + repo.list_refs.return_value = ["ref_A", "ref_B"] + + with pytest.raises(SystemExit) as excinfo: + ops.finalize_work() + + assert excinfo.value.code == 0 + + # Crucially, verify we never switched branches or merged + repo.checkout.assert_not_called() + repo.merge_squash.assert_not_called() + + # --- Roaming Radar & State Tests ---