Skip to content

gravity.nvim: Smart Dotfile Sync

Status: Planning Phase Repository: Fork of kickstart.nvim


Overview

The Name

gravity.nvim captures the core concept: machines pulled into alignment with a central manifest. Like gravity pulling objects into orbit, gravity.nvim brings all development machines into synchronized configuration around a central manifest - fitting naturally within the Sao/Neptune space theme.

The Problem

Development environments suffer from configuration drift - gradual divergence as manual changes accumulate:

  • Dotfile chaos: Local configs drift from repository versions
  • Manual sync pain: Copying configs between machines causes errors
  • Setup friction: Fresh machines require extensive manual configuration
  • No conflict detection: Accidentally overwrite local changes
  • Template hell: .gitconfig needs different emails per machine

Current Reality

  • Machine A: Perfect .tmux.conf, .gitconfig has wrong email
  • Machine B: .tmux.conf outdated, breaks muscle memory
  • Machine C: Fresh install, configs manually copied, some forgotten

The Solution

A smart dotfile sync plugin for Neovim that handles one thing exceptionally well: keeping configuration files synchronized across machines with intelligent conflict detection, backups, and machine-specific overrides.

Core Philosophy: Do dotfile sync perfectly. Let AI handle everything else.

What This Is

gravity.nvim v1.0:

  • ✅ Sync dotfiles from dotfiles/ to ~/ with hash-based change detection
  • ✅ Two override systems for different purposes:
  • Manifest overrides (manifest.overrides.json) for dependency/metadata changes
  • Dotfile overrides (dotfiles.overrides/) for machine-specific config files
  • ✅ Conflict detection with diffs before overwriting
  • ✅ Automatic backups with timestamps
  • ✅ Fast status checks

What This Is NOT

Out of scope (handled by separate AI agent):

  • ❌ Installing packages (apt/brew/pacman)
  • ❌ Installing languages (Go, Node.js, Python)
  • ❌ Installing tools (Docker, tmux, ripgrep)
  • ❌ Platform detection and adaptation
  • ❌ System-level configuration

The AI Agent Approach

Instead of hardcoding installers for every platform, a separate AI bootstrap agent reads the manifest and figures out how to install dependencies on any platform.


Architecture

Separation of Concerns

graph TB
    subgraph "gravity.nvim (Core Plugin)"
        A[Load Manifests] --> B[Detect Changes]
        B --> C[Show Diffs]
        C --> D[User Approval]
        D --> E[Sync Files]
        E --> F[Update State]
    end

    subgraph "AI Bootstrap Agent (Separate)"
        G[Read Dependencies] --> H[Detect Platform]
        H --> I[Research Install Methods]
        I --> J[Generate Script]
        J --> K[User Approves]
        K --> L[Execute]
    end

    M[manifest.json] --> A
    M --> G

    style A fill:#2d5
    style G fill:#25d

Usage Flow: Parallel to Mason

graph TB
    subgraph "Mason (Language Tooling)"
        M1[User opens Neovim]
        M2[Mason reads ensure_installed]
        M3[Checks what's missing]
        M4[Auto-installs LSPs/formatters]
        M5[gopls, gofumpt, stylua, etc.]
        M6["User can update: :Mason"]

        M1 --> M2
        M2 --> M3
        M3 --> M4
        M4 --> M5
        M5 -.-> M6
    end

    subgraph "gravity.nvim (System Configuration)"
        G1[User opens Neovim]
        G2[gravity reads manifest.json]
        G3[Checks dotfile status]
        G4[Shows status/diffs]
        G5[.tmux.conf, .bash_aliases, etc.]
        G6["User syncs: :GravitySync"]

        G1 --> G2
        G2 --> G3
        G3 --> G4
        G4 --> G5
        G5 -.-> G6
    end

    subgraph "Shared Patterns"
        P1[Declarative manifest]
        P2[Automatic dependency handling]
        P3[Update on git pull]
        P4[Part of kickstart fork]
    end

    M2 -.-> P1
    G2 -.-> P1
    M4 -.-> P2
    G6 -.-> P2
    M2 -.-> P3
    G2 -.-> P3
    M1 -.-> P4
    G1 -.-> P4

    style M1 fill:#4a9
    style G1 fill:#4a9
    style P1 fill:#fa4
    style P2 fill:#fa4
    style P3 fill:#fa4
    style P4 fill:#fa4

Key Insight: gravity.nvim extends the kickstart.nvim pattern to system-level configuration. Mason handles dev tools inside Neovim, gravity handles dotfiles outside Neovim. Both use the same declarative, manifest-driven approach that makes kickstart.nvim so effective.

Key Design Decisions

Decision: Pure Lua Plugin vs. Shell Scripts

Pure Lua for core plugin, AI agent for system setup. Clean separation prevents feature creep.

Decision: Declarative Dependencies

Manifest declares what to install, not how. AI figures out the "how" based on current platform.

Decision: Hash-Based Change Detection

SHA256 hashes in .sync_state.json for three-way merge detection (repo vs. system vs. previous). Accurate change detection with minimal storage.

Directory Structure

~/.config/nvim/
├── init.lua                      # Entry point
├── .gitignore                    # Ignores overrides & state files
├── manifest.json                 # Base config (always tracked)
├── manifest.overrides.json       # Manifest overrides (gitignored)
├── .sync_state.json              # State tracking (gitignored)
├── dotfiles/                     # Base dotfiles (committed)
│   ├── .tmux.conf
│   ├── .bash_aliases
│   ├── .gitmux.conf
│   └── .gitconfig
├── dotfiles.overrides/           # Machine-specific dotfile overrides (gitignored)
│   └── .gitconfig                # Example: work machine with different email
├── lua/custom/gravity/
│   ├── init.lua                  # Commands
│   ├── manifest.lua              # Load manifest with deep merge
│   ├── sync.lua                  # Dotfile sync logic with override precedence
│   ├── check.lua                 # Dependency checks
│   └── utils.lua                 # Hashing, backups, diffs, deep merge
└── .claude/
    └── bootstrap-agent.md        # AI agent prompt

.gitignore Contents:

# Machine-specific overrides (never tracked)
manifest.overrides.json
dotfiles.overrides/

# State files (never tracked)
.sync_state.json
backups/

Two Override Systems

gravity.nvim provides two complementary override mechanisms for different purposes:

1. Manifest Overrides (Structured Data)

File: manifest.overrides.json (gitignored)

Purpose: Override specific fields in manifest (dependencies, package versions, metadata)

Mechanism: Deep merge - can change individual fields without redefining entire sections

Use cases:

  • Change Node version from 18 → 16 on this machine
  • Add one extra system package without duplicating entire list
  • Override Go version for testing

Why deep merge?: We control the manifest JSON schema. Structured data with known fields is naturally mergeable.

Example:

// manifest.overrides.json
{
  "version": "1.0.0",
  "dependencies": {
    "languages": {
      "node": "16"  // Override just Node version, inherit rest
    },
    "system_packages": ["postgresql-client"]  // Add to base list
  }
}

2. Dotfile Overrides (File Replacement)

Directory: dotfiles.overrides/ (gitignored)

Purpose: Replace entire dotfiles per machine

Mechanism: File precedence - if override exists, use it; else use base

Use cases:

  • Work machine needs completely different .gitconfig
  • Development machine has custom .tmux.conf keybindings
  • Production machine requires specific .bash_aliases

Why file replacement?: External file formats (.conf, .ini, shell scripts) can't be partially merged without understanding their syntax. Safer and simpler to replace entire file.

Example:

dotfiles/.gitconfig              # Base: Mike Bros <noreply@example.com>
dotfiles.overrides/.gitconfig    # Work: Mike Bros <mike@work.com>

When to Use Which System

Scenario Use Reason
Change Node version Manifest override Single field change in structured data
Add one system package Manifest override Append to existing list
Work machine Git email Dotfile override Email appears in multiple sections of .gitconfig
Custom tmux prefix Dotfile override Keybinding change affects multiple config lines
Test with older Go Manifest override Version number swap
Production bash aliases Dotfile override Completely different command set

Manifest Schema

Base Structure

Just declares what, not how:

{
  "version": "1.0.0",

  "dependencies": {
    "system_packages": ["build-essential", "git", "curl", "tmux", "ripgrep"],
    "languages": {
      "go": "1.25.3",
      "node": "18"
    },
    "tools": ["docker", "neovim"]
  },

  "dotfiles": {
    ".tmux.conf": {
      "source": "dotfiles/.tmux.conf",
      "target": "~/.tmux.conf"
    },
    ".bash_aliases": {
      "source": "dotfiles/.bash_aliases",
      "target": "~/.bash_aliases"
    },
    ".gitconfig": {
      "source": "dotfiles/.gitconfig",
      "target": "~/.gitconfig"
    }
  }
}

Understanding the Version Field

The version field is a schema format version, not a project version:

  • Purpose: Ensures manifest compatibility between base and overrides
  • Not for project versioning: Git commits provide project versioning
  • For format versioning: Breaking changes to manifest structure require version bump

Version validation: Plugin checks that manifest.overrides.json version matches manifest.json version. Mismatched versions produce clear error message.

When version changes: Breaking changes to manifest structure (e.g., dependencies becomes deps, new required fields) require version bump.

Manifest Deep Merge

How override merging works:

function merge_manifests(base, override)
  -- Validate versions match
  if base.version ~= override.version then
    error("Version mismatch: base=" .. base.version .. ", override=" .. override.version)
  end

  -- Deep merge logic
  return deep_merge(base, override)
end

Deep merge rules:

  • Objects: Merge keys recursively
  • Arrays: Override replaces base (append would cause duplicates)
  • Primitives: Override value replaces base value

Example merge:

-- Base manifest.json
{
  "dependencies": {
    "languages": { "go": "1.25.3", "node": "18" },
    "system_packages": ["git", "curl"]
  }
}

-- Override manifest.overrides.json
{
  "dependencies": {
    "languages": { "node": "16" },
    "system_packages": ["git", "curl", "postgresql-client"]
  }
}

-- Result after merge
{
  "dependencies": {
    "languages": { "go": "1.25.3", "node": "16" },  -- node overridden, go inherited
    "system_packages": ["git", "curl", "postgresql-client"]  -- array replaced
  }
}

Dotfile Override Precedence

File-based override system:

  • Base dotfiles in dotfiles/ (committed to git)
  • Machine-specific versions in dotfiles.overrides/ (gitignored)
  • Sync checks override directory first, falls back to base

Sync logic:

function get_source_file(dotfile_name)
  local override = "dotfiles.overrides/" .. dotfile_name
  local base = "dotfiles/" .. dotfile_name

  if file_exists(override) then
    return override
  else
    return base
  end
end

Result: Simple file precedence for configs that can't be easily merged.


Shipping Strategy

How Overrides Work

Initial Repository State:

  • manifest.json - Always tracked by Git
  • dotfiles/ - Base dotfiles tracked by Git
  • manifest.overrides.json.example - Starter template (tracked, renamed by user)
  • dotfiles.overrides/ - Created by user, always gitignored
  • .gitignore - Contains both manifest.overrides.json and dotfiles.overrides/

Workflow:

  1. User clones repo → gets base manifest and dotfiles
  2. For manifest overrides (optional):
  3. Copy starter template: cp manifest.overrides.json.example manifest.overrides.json
  4. Edit to override specific fields (Node version, extra packages, etc.)
  5. For dotfile overrides (optional):
  6. Create directory: mkdir -p dotfiles.overrides/
  7. Copy base file: cp dotfiles/.gitconfig dotfiles.overrides/.gitconfig
  8. Edit for machine-specific values
  9. Git never tracks overrides (both in .gitignore)
  10. Updates to base files pull cleanly (no conflicts)

Commands

Core Commands (v1.0)

Command Description Use Case
:GravitySync Sync dotfiles with prompts/diffs After git pull, apply config changes
:GravityStatus Show which dotfiles differ Quick check before syncing
:GravityDiff <file> Show diff for specific file Review changes before applying

Optional Commands (v1.1)

Command Description Use Case
:GravityCheck Check if dependencies installed Verify system state (read-only)
:GravityBootstrap Launch AI agent with prompt Fresh machine setup

Implementation Timeline

Day 1 Morning

Goal: Basic plugin structure and status checking

Tasks:

  1. Fork kickstart.nvim, create directory structure
  2. Implement manifest.lua (load base + override manifests, validate version, deep merge)
  3. Implement utils.lua (SHA256 hashing, file operations, deep merge utility)
  4. Implement sync.lua (load state, compute hashes, detect changes, dotfile override precedence)
  5. Implement :GravityStatus command

Deliverables: :GravityStatus shows which dotfiles differ from repo, handles both override systems


Day 1 Afternoon

Goal: File syncing with safety

Tasks:

  1. Implement backup system (~/.config/nvim/backups/ with timestamps)
  2. Implement sync operations (repo → system with prompts)
  3. Update .sync_state.json after sync
  4. Implement :GravitySync command with user confirmations
  5. Test on real dotfiles

Deliverables: :GravitySync copies files with backups and updates state


Day 2 Morning

Goal: Conflict detection and diffs

Tasks:

  1. Implement three-way conflict detection (repo vs. system vs. previous)
  2. Implement :GravityDiff command (show changes before applying)
  3. Test conflict scenarios with overrides

Deliverables: Detects conflicts, shows diffs, handles overrides correctly


Day 2 Afternoon

Goal: AI agent and polish

Tasks:

  1. Write AI agent prompt (.claude/bootstrap-agent.md)
  2. Implement :GravityCheck (check dependencies exist, don't install)
  3. Implement :GravityBootstrap (launch Claude with prompt)
  4. Improve output formatting (clear status tables, colored diffs)
  5. Write README with workflows and examples

Deliverables: Complete plugin with AI agent integration


User Workflows

Workflow 1: Fresh Machine Setup

# 1. Clone repo
git clone https://github.com/Mike-Bros/gravity.nvim.git ~/.config/nvim

# 2. Open Neovim
nvim

# 3. Bootstrap system (AI agent figures out how to install on this platform)
:GravityBootstrap
# Claude: "I see you need Go 1.25.3, Node 18, Docker, ripgrep..."
# Claude: "Detected Ubuntu 22.04. Here's the install script:"
# Shows commands, user approves, executes

# 4. Sync dotfiles
:GravitySync
# Shows what will be copied (uses base dotfiles)
# User confirms, files synced

# 5. Create overrides if needed (optional)
mkdir -p ~/.config/nvim/dotfiles.overrides
cp ~/.config/nvim/dotfiles/.gitconfig ~/.config/nvim/dotfiles.overrides/.gitconfig
nvim ~/.config/nvim/dotfiles.overrides/.gitconfig
# Edit for machine-specific values

Workflow 2: Sync Config Changes (Machine A → B)

Machine A (make and push change):

nvim ~/.config/nvim/dotfiles/.tmux.conf  # Change prefix to Ctrl+a
cd ~/.config/nvim
git add dotfiles/.tmux.conf
git commit -m "Update tmux prefix"
git push

Machine B (pull and apply):

cd ~/.config/nvim && git pull
nvim
:GravityStatus

Output:

Dotfile Status:
✓ .bash_aliases - unchanged
✓ .gitmux.conf - unchanged
→ .tmux.conf - repo changed (2 minutes ago)
✓ .gitconfig - unchanged
:GravitySync

Output:

Sync Plan:
→ .tmux.conf
  - Backing up to ~/.config/nvim/backups/.tmux.conf.2025-11-04_143022
  - Copying dotfiles/.tmux.conf → ~/.tmux.conf

Apply changes? [y/N]: y

✓ Synced 1 file, 3 unchanged

Workflow 3: Manifest Override (Dependency Version)

Scenario: Testing machine needs older Node version

# Copy starter template
cp ~/.config/nvim/manifest.overrides.json.example ~/.config/nvim/manifest.overrides.json

# Edit to override Node version
nvim ~/.config/nvim/manifest.overrides.json

Content of manifest.overrides.json:

{
  "version": "1.0.0",
  "dependencies": {
    "languages": {
      "node": "16"  // Override just Node version
    }
  }
}

Run bootstrap:

nvim
:GravityBootstrap
# Claude sees merged manifest: Node 16 (from override), Go 1.25.3 (from base)
# Installs Node 16 instead of 18

Result: Testing machine uses Node 16, production machines use Node 18 (base). No git conflicts.


Workflow 4: Dotfile Override (Configuration File)

Scenario: Work machine needs different Git email

# Create overrides directory
mkdir -p ~/.config/nvim/dotfiles.overrides

# Copy base file to override
cp ~/.config/nvim/dotfiles/.gitconfig ~/.config/nvim/dotfiles.overrides/.gitconfig

# Edit override for work email
nvim ~/.config/nvim/dotfiles.overrides/.gitconfig
# Change email to mike@work.com

# Sync dotfiles
nvim
:GravitySync
# Uses dotfiles.overrides/.gitconfig instead of dotfiles/.gitconfig
# Copies to ~/.gitconfig with work email

Result: Work machine has work email, personal machine uses base email. No git conflicts (overrides gitignored).


AI Bootstrap Agent

Purpose

Read manifest.json, detect platform, research current installation methods, generate platform-specific install script.

Implementation

Location: .claude/bootstrap-agent.md

Process:

  1. Read manifest.json
  2. Detect platform (uname -s, /etc/os-release, package managers)
  3. For each dependency, search for current install method
  4. Generate idempotent install script
  5. Show commands to user for approval
  6. Execute approved script
  7. Verify installations

Platform Support

AI adapts to any platform:

  • Ubuntu/Debian: apt, PPAs, .deb packages
  • Arch: pacman, AUR helpers
  • macOS: Homebrew, curl installers
  • NixOS: Nix expressions
  • Unknown: AI researches and adapts

User Control

  • Shows all commands before running
  • User approves/rejects each step
  • Can edit script before execution
  • Clear explanations of what each command does

Example Interaction

User: :GravityBootstrap

Claude: I'll set up your development environment based on manifest.json.

Detected platform: Ubuntu 22.04 LTS

Dependencies to install:

- System packages: build-essential, git, curl, tmux, ripgrep
- Languages: Go 1.25.3, Node.js 18
- Tools: Docker

Generated install script:

```bash
#!/bin/bash
set -e

# Update package list
sudo apt-get update

# Install system packages
sudo apt-get install -y build-essential git curl tmux ripgrep

# Install Go 1.25.3
wget https://go.dev/dl/go1.25.3.linux-amd64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.25.3.linux-amd64.tar.gz
rm go1.25.3.linux-amd64.tar.gz

# Install Node.js 18 via NVM
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
source ~/.bashrc
nvm install 18

# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
rm get-docker.sh

State Tracking

Three-Way Conflict Detection

Core Concept: Track the state at last sync to detect what changed since then. Without this, you only know "repo ≠ system" but can't tell which side changed.

stateDiagram-v2
    [*] --> LastSync: Initial sync
    LastSync --> Unchanged: No changes
    LastSync --> RepoChanged: Only repo changed
    LastSync --> SystemChanged: Only system changed
    LastSync --> Conflict: Both changed

    Unchanged --> [*]: Skip
    RepoChanged --> [*]: Copy repo → system
    SystemChanged --> [*]: Warn before overwrite
    Conflict --> [*]: Show diff, user decides

    note right of LastSync
        Remembers state at last sync:
        - repo_hash
        - system_hash
    end note

    note right of RepoChanged
        Safe: System unchanged,
        repo has updates
    end note

    note right of SystemChanged
        Warning: User edited locally,
        don't lose work
    end note

    note right of Conflict
        Both sides changed since
        last sync - needs resolution
    end note

Four States Explained

Tracking last sync state enables detection of four distinct states:

State Last Sync Repo Now System Now Action
Unchanged A A A Skip
Repo Changed A B A Copy repo → system (safe)
System Changed A A B Warn before overwrite
Conflict A B C Show diff, user decides

Why this matters: Without tracking last sync (only comparing repo vs. system), you can't distinguish "system changed" from "repo changed" - you just see they differ and don't know which to trust.

State File Structure

.sync_state.json - Complete structure with all tracked fields:

{
  "manifest_hash": "abc123...",

  "dotfiles": {
    ".tmux.conf": {
      "source_hash": "def456...",
      "system_hash": "def456...",
      "last_sync": "2025-11-04T14:30:22Z"
    },

    ".gitconfig": {
      "source_hash": "ghi789...",
      "system_hash": "jkl012...",
      "used_override": true,
      "last_sync": "2025-11-04T14:30:22Z"
    }
  }
}

Field Descriptions:

  • manifest_hash: SHA256 of merged manifest (base + overrides) - detects changes to dependencies or dotfile definitions
  • source_hash: SHA256 of source file (either dotfiles/ or dotfiles.overrides/)
  • system_hash: SHA256 of target file in ~/
  • used_override: Boolean flag indicating if dotfile override was used (optional, for debugging)
  • last_sync: ISO 8601 timestamp of last successful sync

Override Detection

How the plugin detects override changes:

The plugin tracks which source file was used (source_hash) regardless of whether it came from dotfiles/ or dotfiles.overrides/. Change detection works the same way:

  1. Determine source file (check override first, fall back to base)
  2. Compute source file hash
  3. Compare to source_hash in state

Override switching detection:

If user creates an override file after syncing base, the source path changes: - Previous sync: dotfiles/.gitconfig → hash A - Current sync: dotfiles.overrides/.gitconfig exists → hash B - Plugin detects source changed, marks as repo_changed

Hashing Performance

Why hashing instead of full file comparison?

Without hashing:

  • Must read and compare full file contents
  • State file stores entire file snapshots
  • Large state files for many dotfiles

With hashing:

  • Hash file contents to 64-byte SHA256 fingerprint
  • Compare short fingerprints instead
  • Minimal state file storage

Conflict Detection Implementation

Complete three-way detection with override support:

function detect_change_type(file)
  local state = load_sync_state()

  -- Determine source file (override or base)
  local source_file = get_source_file(file)
  local source_hash = hash_file(source_file)
  local system_hash = hash_file(expand_path("~/" .. file))

  -- Get previous state
  local prev = state.dotfiles[file] or {}

  -- Unchanged: all match
  if source_hash == system_hash and source_hash == prev.source_hash then
    return "unchanged"
  end

  -- Detect what changed
  local source_changed = (source_hash ~= prev.source_hash)
  local system_changed = (system_hash ~= prev.system_hash)

  -- Three-way detection
  if source_changed and system_changed then
    return "conflict"
  elseif source_changed then
    return "source_changed"
  elseif system_changed then
    return "system_changed"
  end
end

Change Types

Type Description Action
unchanged All hashes match Skip
source_changed Source file changed (base or override) Copy source → system (safe)
system_changed Only system changed Warn (or copy system → repo in future)
conflict Both changed Show diff, require user decision

Testing

Overview

gravity.nvim includes a comprehensive test suite to validate all core functionality. The testing approach focuses on isolated test environments that prevent side effects on the real system while ensuring fast execution.

Test Framework: plenary.nvim (standard Neovim testing framework)

Test Statistics:

  • Total Tests: 13 unit tests
  • Execution Time: ~2 seconds for full suite
  • Coverage: Status detection, override precedence, state tracking
  • Isolation: Each test uses temporary directories (no real file modifications)

Test Coverage

The test suite covers three critical areas of gravity.nvim functionality:

Status Detection Tests (7 tests)

Complete validation of three-way merge detection logic:

Test Case Symbol Description
Missing System File exists in repo but not yet on system
Out of Sync File exists on system but never synced (no state tracking)
Unchanged Source and system files identical, hashes match state
Source Changed Source file modified since last sync, system unchanged
System Changed System file modified since last sync, source unchanged
Conflict Both source and system changed since last sync
Missing Source Source file deleted but system file remains

Why these matter: Three-way detection prevents data loss by distinguishing between safe updates (source changed), dangerous overwrites (system changed), and conflicts requiring user intervention.

Override Precedence Tests (3 tests)

Validates the dotfile override mechanism works correctly:

Test Case Validates
Base file used When no override exists, sync uses dotfiles/ file
Override file used When override exists, sync uses dotfiles.overrides/ file
Override content synced Override file content correctly copied to system

Why these matter: Override precedence ensures machine-specific configurations (work email, custom keybindings) take precedence over base configurations without git conflicts.

State Tracking Tests (3 tests)

Verifies state persistence and hash tracking:

Test Case Validates
State file creation .sync_state.json created after first sync
Hash tracking Source and system hashes stored correctly
Override tracking used_override field records which source was used

Why these matter: Accurate state tracking is the foundation of three-way merge detection. Without it, conflict detection fails.

Test Architecture

The test suite creates isolated environments to ensure tests don't modify real system files:

graph TB
    subgraph "Test Suite"
        T1[tests/sync_spec.lua]
        T2[plenary test runner]
    end

    subgraph "Test Environment (Per Test)"
        E1[Temp Directory]
        E2[Mock dotfiles/]
        E3[Mock dotfiles.overrides/]
        E4[Mock home/]
        E5[Test manifest.json]
        E6[Test .sync_state.json]
    end

    subgraph "Test Execution Flow"
        F1[Setup: Create temp dirs]
        F2[Arrange: Write test files]
        F3[Act: Call gravity function]
        F4[Assert: Check result]
        F5[Cleanup: Delete temp dirs]
    end

    subgraph "Coverage Areas"
        C1[Status Detection<br/>7 tests]
        C2[Override Precedence<br/>3 tests]
        C3[State Tracking<br/>3 tests]
    end

    T1 --> T2
    T2 --> E1
    E1 --> E2
    E1 --> E3
    E1 --> E4
    E1 --> E5
    E1 --> E6

    T2 --> F1
    F1 --> F2
    F2 --> F3
    F3 --> F4
    F4 --> F5

    T1 --> C1
    T1 --> C2
    T1 --> C3

    style T1 fill:#4a9
    style C1 fill:#2d5
    style C2 fill:#2d5
    style C3 fill:#2d5

Test Isolation Strategy

Each test runs in complete isolation from the real system:

Mock Functions:

  • vim.fn.stdpath('config') → Points to temp directory
  • HOME environment variable → Points to temp home directory
  • All file operations → Contained within temp directories

Test Structure:

  1. Setup: Create isolated temp directory structure
  2. Arrange: Write test files with specific content and hashes
  3. Act: Execute gravity.nvim sync/status functions
  4. Assert: Verify correct status detection, file operations, state updates
  5. Cleanup: Delete temp directories (automatic via temp dir library)

Benefits:

  • Tests run in parallel without conflicts
  • No risk of corrupting real dotfiles
  • Fast execution (no real file I/O overhead)
  • Repeatable test environments

Running Tests

Quick Run (recommended):

cd ~/.config/nvim
./tests/run_tests.sh

Manual Run (with full command):

nvim --headless -c "PlenaryBustedDirectory tests/ { minimal_init = 'tests/minimal_init.lua' }"

Run Specific Test File:

nvim --headless -c "PlenaryBustedFile tests/sync_spec.lua { minimal_init = 'tests/minimal_init.lua' }"

Test Output Format:

Testing: /home/user/.config/nvim/tests/sync_spec.lua
Success: sync status detection > detects unchanged files
Success: sync status detection > detects source changed
Success: sync status detection > detects system changed
Success: sync status detection > detects conflicts
...

13 tests passed in 1.8s

Test File Location

Test files follow standard Neovim plugin structure:

~/.config/nvim/
├── lua/custom/gravity/
│   ├── init.lua           # Production code
│   ├── sync.lua
│   ├── manifest.lua
│   └── utils.lua
├── tests/
│   ├── minimal_init.lua   # Minimal Neovim config for testing
│   ├── sync_spec.lua      # All 13 unit tests
│   └── run_tests.sh       # Convenience script
└── README.md

Example Test Case

Test: Detect source changed

it("detects source changed status", function()
  -- Setup: Create temp environment
  local temp_dir = create_temp_dir()
  local dotfiles_dir = temp_dir .. "/dotfiles"
  local home_dir = temp_dir .. "/home"

  -- Arrange: Create files with initial state
  write_file(dotfiles_dir .. "/.tmux.conf", "original content")
  write_file(home_dir .. "/.tmux.conf", "original content")

  -- Simulate first sync (create state)
  local state = {
    dotfiles = {
      [".tmux.conf"] = {
        source_hash = hash("original content"),
        system_hash = hash("original content")
      }
    }
  }
  write_state(state)

  -- Act: Modify source file
  write_file(dotfiles_dir .. "/.tmux.conf", "new content")

  local status = detect_change_type(".tmux.conf")

  -- Assert: Should detect source changed
  assert.equals("source_changed", status)
end)

Continuous Integration

Future enhancement: Run tests automatically on git push via GitHub Actions.


Success Criteria

Criterion Measurement
No data loss Backups before every overwrite
Conflict detection Catch all three-way conflicts
Cross-machine consistency Hash verification

Future Enhancements (v1.1+)

Enhancement Description Effort
System → Repo sync Copy local changes back to repo 1 day
Auto-commit Commit changes with formatted messages 4 hours
Backup rotation Keep only last N backups per file 2 hours
Encrypted vars Integration with password managers 2 days
Visual diff Side-by-side diff in floating window 1 day
Multi-platform testing Test AI agent on Arch, macOS, NixOS 2 days

Risks and Mitigations

Risk Impact Mitigation Status
Data loss High Always backup, require confirmation ✅ Mitigated
Overwrite conflicts Medium Three-way detection, show diffs ✅ Mitigated
AI agent failures Medium Show commands before running, user approval ✅ Mitigated
Missing dependencies Low :GravityCheck detects before use ✅ Mitigated
Template errors Low Validate before writing, clear error messages ✅ Mitigated

Comparison: Both Override Systems

Aspect Manifest Overrides Dotfile Overrides
File/Directory manifest.overrides.json dotfiles.overrides/
Mechanism Deep merge of JSON structures File precedence (check override first)
Use case Dependency versions, package lists Complete config files (.gitconfig, .tmux.conf)
Granularity Change one field, inherit rest Replace entire file
User workflow Edit JSON to override specific fields Copy base file, edit override version
Git conflicts None (overrides gitignored) None (overrides gitignored)
Implementation ~100 LOC deep merge + validation ~50 LOC file precedence check
Mental model Field-level inheritance File replacement
Why this way? Structured data we control → merge safely External formats → can't parse, replace safely

Next Steps

  1. Fork kickstart.nvim
  2. Build Day 1 (manifest loading + status checking + override precedence)
  3. Build Day 2 (syncing + conflict detection + AI agent)
  4. Test on multiple machines
  5. Ship v1.0