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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` | 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

Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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."
Expand Down
34 changes: 34 additions & 0 deletions src/git_pulsar/git_wrapper.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import re
import subprocess
from pathlib import Path

Expand Down Expand Up @@ -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
45 changes: 36 additions & 9 deletions src/git_pulsar/ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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",
Expand All @@ -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()

Expand Down
2 changes: 2 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

---
Expand Down
23 changes: 23 additions & 0 deletions tests/test_git_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
54 changes: 39 additions & 15 deletions tests/test_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 ---


Expand Down