From bcf0eac729d4fa2d264cf605f443d644cfd874f0 Mon Sep 17 00:00:00 2001 From: Jackson Ferguson Date: Mon, 23 Feb 2026 17:13:40 -0800 Subject: [PATCH 1/6] feat(git): add diff_shortstat wrapper for telemetry Introduces `diff_shortstat` to the `GitRepo` class to wrap `git diff --shortstat` using regex. This allows for deterministic extraction of files changed, insertions, and deletions between two revisions without parsing raw patch output. --- src/git_pulsar/git_wrapper.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) 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 From e8c7a7e36937ed94d45477cb02f2253d4c28c2e4 Mon Sep 17 00:00:00 2001 From: Jackson Ferguson Date: Mon, 23 Feb 2026 17:15:16 -0800 Subject: [PATCH 2/6] feat(ops): implement interactive pre-flight checklist for finalize Halts the `finalize` execution flow prior to the branch switch to display a `rich` table summary of all candidate backup streams. Aggregates machine IDs, relative timestamps, and diff statistics to negotiate the merge with the user. Prevents stranding the user on the target branch if aborted. --- src/git_pulsar/ops.py | 45 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 9 deletions(-) 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() From 5207a7fb1e5e256ffb96455f30c4445076885d2d Mon Sep 17 00:00:00 2001 From: Jackson Ferguson Date: Mon, 23 Feb 2026 17:22:57 -0800 Subject: [PATCH 3/6] test: fix finalize mock rendering and add shortstat regex coverage Fixes a crash in `test_finalize_octopus_merge` where `rich.table` failed to render a `MagicMock` object by explicitly returning a string for the commit time. Adds `test_finalize_aborts_on_user_decline` to ensure clean exits. Expands `test_git_wrapper.py` with `test_diff_shortstat_regex_parsing` to verify deterministic data extraction when Git omits empty clauses. --- tests/test_git_wrapper.py | 23 +++++++++++++++++ tests/test_ops.py | 54 ++++++++++++++++++++++++++++----------- 2 files changed, 62 insertions(+), 15 deletions(-) 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 --- From 1387485a462c98baef18e34614b96e72ed676826 Mon Sep 17 00:00:00 2001 From: Jackson Ferguson Date: Mon, 23 Feb 2026 17:23:25 -0800 Subject: [PATCH 4/6] docs(tests): document pre-flight checklist and shortstat testing layers Updates `tests/README.md` to reflect the newly added interactive dry-run logic for the `finalize` command and the regex parsing determinism in the Git abstraction layer. --- tests/README.md | 2 ++ 1 file changed, 2 insertions(+) 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. --- From 10cea4ce5dbeccbb19183eae585b5f14c3583f39 Mon Sep 17 00:00:00 2001 From: Jackson Ferguson Date: Mon, 23 Feb 2026 17:30:07 -0800 Subject: [PATCH 5/6] docs(src): document pre-flight checklist and shortstat parsing layers Updates `src/README.md` to reflect the newly added interactive dry-run negotiation for the `finalize` command in the `ops` module, and the regex parsing determinism added to the Git abstraction layer. --- src/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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." From 2448fe98cf9bfaa4040282b9af0b4465586de2fd Mon Sep 17 00:00:00 2001 From: Jackson Ferguson Date: Mon, 23 Feb 2026 17:32:11 -0800 Subject: [PATCH 6/6] docs(readme): mark pre-flight checklist as complete Updates the root README to check off the "Pre-Flight Checklists" item under the Phase 1 roadmap. Slightly adjusts the `finalize` command description in the reference table to highlight the new interactive dry-run safety feature. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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)