Heimdal uses a hybrid state management system inspired by Terraform to coordinate configuration changes across multiple machines safely and efficiently.
- Overview
- Core Concepts
- State File Structure
- Locking Mechanism
- Conflict Detection & Resolution
- CLI Commands
- Multi-Machine Workflows
- Troubleshooting
When managing dotfiles across multiple machines, several challenges arise:
- Concurrent Modifications: Two machines updating state simultaneously can lead to lost changes
- Version Drift: Different machines may have different versions of the state
- Conflict Resolution: How to merge divergent states without losing data
- Binary Compatibility: Ensuring different Heimdal versions can work together
Heimdal implements a hybrid locking system that combines:
- Local file locks for fast, single-machine operations
- Git-based coordination for multi-machine synchronization
- State versioning with automatic migration
- Conflict detection using lineage tracking
Local Machine Git Repository (Remote)
┌──────────────┐ ┌─────────────────────┐
│ state.json │───sync via────▶│ state.json │
│ state.lock │ git │ lock metadata │
│ versioning │◀───────────────│ conflict detection │
└──────────────┘ └─────────────────────┘
Heimdal uses schema versioning to evolve the state file format safely.
Current Version: V2
Key Features:
- Automatic V1→V2 migration
- Backward compatibility
- Version tracking for binary compatibility
Schema Evolution:
V1 (Legacy) V2 (Current)
├── Basic state ├── Enhanced state
├── Simple tracking ├── Machine metadata
└── No versioning ├── State lineage
├── Operation history
├── File checksums
└── Lock coordination
Each machine is uniquely identified:
{
"machine": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"hostname": "macbook-pro",
"os": "darwin",
"arch": "aarch64",
"user": "john"
}
}Tracks state evolution to detect conflicts:
{
"lineage": {
"id": "lineage-abc123",
"serial": 42,
"parent_serial": 41,
"git_commit": "a1b2c3d",
"machines": ["machine-1", "machine-2"]
}
}Serial Numbers:
- Increment with each state write
- Used to detect concurrent modifications
- Parent serial tracks the previous version
Maintains an audit trail of the last 50 operations:
{
"history": [
{
"operation": "apply",
"timestamp": "2024-01-15T10:30:00Z",
"machine_id": "machine-1",
"user": "john",
"serial": 42
}
]
}Detects out-of-band file modifications:
{
"checksums": {
".bashrc": "5d41402abc4b2a76b9719d911017c592",
".vimrc": "098f6bcd4621d373cade4e832627b4f6"
}
}macOS: ~/.heimdal/state.json
Linux: ~/.heimdal/state.json
Windows: %USERPROFILE%\.heimdal\state.json
{
"version": 2,
"active_profile": "personal",
"dotfiles_path": "/Users/john/.dotfiles",
"repo_url": "git@github.com:john/dotfiles.git",
"last_sync": "2024-01-15T10:30:00Z",
"last_apply": "2024-01-15T10:35:00Z",
"machine": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"hostname": "macbook-pro",
"os": "darwin",
"arch": "aarch64",
"user": "john"
},
"heimdal_version": "1.0.0",
"lineage": {
"id": "lineage-abc123",
"serial": 42,
"parent_serial": 41,
"git_commit": "a1b2c3d4e5f6",
"machines": ["machine-1", "machine-2", "machine-3"]
},
"history": [
{
"operation": "apply",
"timestamp": "2024-01-15T10:30:00Z",
"machine_id": "machine-1",
"user": "john",
"serial": 42
}
],
"checksums": {
".bashrc": "5d41402abc4b2a76b9719d911017c592",
".vimrc": "098f6bcd4621d373cade4e832627b4f6"
}
}-
Local Lock (Fast, single-machine)
- File-based lock at
~/.heimdal/state.lock - Prevents concurrent operations on the same machine
- Automatic stale process detection
- File-based lock at
-
Hybrid Lock (Multi-machine coordination)
- Acquires local lock first
- Then coordinates with remote Git repository
- Detects conflicts before allowing operations
- Falls back to local-only if remote unavailable
-
Disabled (Dangerous!)
- No locking at all
- Only use for debugging/testing
- Can lead to data loss
{
"id": "lock-uuid-123",
"lock_type": "hybrid",
"operation": "apply",
"machine": {
"id": "machine-1",
"hostname": "macbook-pro",
"pid": 12345,
"user": "john"
},
"created_at": "2024-01-15T10:30:00Z",
"expected_duration_seconds": 300,
"reason": "Applying configuration",
"state_serial": 42
}┌─────────────────┐
│ Operation Start │
└────────┬────────┘
│
▼
┌─────────┐
│ Dry-run?│───Yes──▶ Skip lock
└────┬────┘
│No
▼
┌──────────────────┐
│ Acquire Local │
│ Lock │
└────────┬─────────┘
│
▼
┌─────────────┐
│ Lock Config │
│ = Hybrid? │
└──────┬──────┘
│Yes
▼
┌───────────────┐
│ Fetch Remote │
│ State │
└───────┬───────┘
│
▼
┌───────────────┐
│ Check │
│ Conflicts? │
└───┬───────────┘
│
├─Yes─▶ Release lock, error
│
└─No──▶ Proceed with operation
│
▼
┌───────────────┐
│ RAII Guard │
│ Auto-releases │
└───────────────┘
Heimdal uses RAII (Resource Acquisition Is Initialization) pattern:
{
// Lock acquired
let _guard = StateGuard::acquire(...)?;
// Do work...
apply_configuration()?;
// Lock automatically released when _guard goes out of scope
}Benefits:
- No forgotten unlocks - impossible to forget to release
- Exception-safe - releases even if operation fails
- Panic-safe - releases even if code panics
A lock is considered stale if:
- Process died: The PID no longer exists
- Timeout exceeded: Lock age > configured timeout (default: 5 minutes)
- Machine offline: Remote machine hasn't been seen in 24 hours
Automatic cleanup:
# Manual check
heimdal state lock-info
# Force remove stale lock
heimdal state unlock --forceCause: Two machines modified state from the same parent
Machine A (serial 41) Machine B (serial 41)
│ │
├─ apply ────▶ serial 42 ├─ apply ────▶ serial 42
│ │
└──────── CONFLICT! ───────────┘
Detection:
heimdal state check-conflictsOutput:
🔴 State diverged: local serial 42 vs remote serial 42 (common parent: 41)
Cause: Different active profiles on different machines
Machine A: active_profile = "personal"
Machine B: active_profile = "work"
Impact: Usually benign, but can cause confusion
Cause: Dotfiles path differs between machines
Machine A: /Users/john/.dotfiles
Machine B: /home/john/.dotfiles
Impact: Symlinks and file operations will fail
Cause: File modified outside of Heimdal
# User manually edited .bashrc
vim ~/.bashrc
# Heimdal detects the change
heimdal state check-driftheimdal state resolve --use-localWhen to use:
- You know local state is correct
- Remote changes should be discarded
- Single source of truth needed
Effect:
- Local state is kept
- Remote state will be overwritten on next push
heimdal state resolve --use-remoteWhen to use:
- Remote state is more up-to-date
- Local changes should be discarded
- Trust the remote version
Effect:
- Remote state replaces local
- Local changes are lost
heimdal state resolve --mergeWhen to use:
- Both states have valid changes
- Want to combine the best of both
- Automatic resolution preferred
Merge Logic:
- Use higher serial number
- Combine machine lists
- Use most recent timestamps
- Merge operation history
- Prefer remote checksums for conflicts
Example:
Local: Remote: Merged:
serial: 42 serial: 43 serial: 43
machines: [A, B] machines: [B, C] machines: [A, B, C]
last_sync: 10:00 last_sync: 10:30 last_sync: 10:30
heimdal state resolve --manualWhen to use:
- Conflicts are complex
- Need careful inspection
- Automated resolution might be wrong
Steps:
- Edit:
vim ~/.heimdal/state.json - Validate:
heimdal state version - Commit:
heimdal commit -m "Fix state conflicts" - Push:
git pushorheimdal commit --push
The sync command automatically attempts to resolve conflicts:
heimdal syncFlow:
- Pulls from Git
- Detects state conflicts
- Displays conflicts to user
- Attempts automatic merge
- Falls back to manual if merge fails
Output:
✓ Git pull completed successfully!
⚠️ State Conflicts Detected
1. 🔴 State diverged: local serial 42 vs remote serial 43 (common parent: 41)
2. 🟢 File '.bashrc' has been modified
Attempting automatic merge...
✓ States merged successfully
- Combined 3 machine(s)
- Resolved 2 conflict(s)
✓ State conflicts resolved automatically
heimdal state versionOutput:
State Version: 2
Machine ID: 550e8400-e29b-41d4-a716-446655440000
Serial: 42
heimdal state history [--limit 10]Output:
Recent State Operations (last 10):
42. apply 2024-01-15 10:35:00 macbook-pro john
41. sync 2024-01-15 10:30:00 macbook-pro john
40. apply 2024-01-14 15:20:00 work-laptop john
39. sync 2024-01-14 15:15:00 work-laptop john
heimdal state lock-infoOutput (when locked):
State Lock Information:
Operation: apply
Machine: macbook-pro (550e8400-...)
User: john
PID: 12345
Acquired: 2 minutes ago
Lock ID: lock-uuid-123
Output (when unlocked):
State is not locked
heimdal state unlock --forceWarning: Only use if you're sure the lock is stale!
heimdal state check-conflictsOutput:
⚠️ State Conflicts Detected
1. 🔴 State diverged: local serial 42 vs remote serial 42 (common parent: 41)
2. 🟡 Active profile differs: local 'personal' vs remote 'work'
Resolution options:
1. Use local state: heimdal state resolve --use-local
2. Use remote state: heimdal state resolve --use-remote
3. Merge states: heimdal state resolve --merge
4. Manual edit: heimdal state resolve --manual
# Use local state
heimdal state resolve --use-local
# Use remote state
heimdal state resolve --use-remote
# Intelligent merge
heimdal state resolve --merge
# Manual resolution
heimdal state resolve --manualheimdal state check-drift [--all]Output:
Checking for file drift...
Modified Files (2):
• .bashrc
Expected: 5d41402abc4b2a76b9719d911017c592
Actual: 098f6bcd4621d373cade4e832627b4f6
• .vimrc
Expected: 7d793037a0760186574b0282f2f435e7
Actual: 9d4e1e23bd5b727046a9e3b4b7db57bd
Tip: Run 'heimdal apply' to restore tracked files
or 'heimdal commit' to accept the changes
heimdal state migrate [--no-backup]Output:
Migrating state from V1 to V2...
✓ Backup created: ~/.heimdal/backups/state_v1_20240115_103000.json
✓ State migrated successfully
✓ New state saved
Migration Summary:
- Added machine metadata
- Initialized state lineage (serial: 1)
- Created empty operation history
- Initialized file checksums
Backup Location: ~/.heimdal/backups/
Goal: Set up Heimdal on a second machine
Steps:
-
On Machine A (existing):
# Ensure state is committed and pushed heimdal commit -m "Current state" --push
-
On Machine B (new):
# Clone dotfiles repo git clone git@github.com:you/dotfiles.git ~/.dotfiles # Initialize Heimdal cd ~/.dotfiles heimdal init --profile personal --repo git@github.com:you/dotfiles.git # Sync and apply heimdal sync
-
Verification:
# Check that Machine B is in the lineage heimdal state history # You should see Machine B's machine ID
Problem: Both machines modified state while offline
Symptoms:
heimdal sync
# Error: State conflicts detectedResolution:
-
View conflicts:
heimdal state check-conflicts
-
Choose resolution strategy:
Option A - Automatic merge (recommended):
heimdal state resolve --merge git push
Option B - Keep local changes:
heimdal state resolve --use-local git push --force
Option C - Keep remote changes:
heimdal state resolve --use-remote heimdal apply
-
Sync other machine:
# On the other machine heimdal sync # Pulls and applies changes
Goal: Stop using Heimdal on a machine
Steps:
-
On machine to remove:
# Commit any pending changes heimdal commit -m "Final changes from old-machine" --push # Remove Heimdal (optional) rm -rf ~/.heimdal
-
On remaining machines:
# Pull the final changes heimdal sync # The old machine will remain in history but won't make new changes
Note: The old machine ID will remain in lineage.machines for historical tracking, but won't interfere with operations.
Goal: Use Heimdal without internet connection
How it works:
-
Lock acquisition falls back to local-only:
heimdal apply # ⚠️ Warning: Cannot reach remote repository: connection refused # Proceeding with local-only lock. # Remember to sync when connection is restored.
-
When back online:
heimdal sync # This will detect and resolve any conflicts
Best Practice:
- Commit changes before going offline
- Sync immediately when back online
- Resolve conflicts promptly
Symptom:
Error: State is currently locked
Operation: apply
Machine: macbook-pro (550e8400-...)
User: john
PID: 12345
Acquired: 10 minutes ago
This means another Heimdal operation is in progress.
Solutions:
-
Wait for operation to complete (preferred)
- Check if another terminal has Heimdal running
- Wait for that operation to finish
-
Check if process is still alive:
ps aux | grep 12345 -
If process died, force unlock:
heimdal state unlock --force
Symptom:
🔴 State diverged: local serial 42 vs remote serial 43 (common parent: 41)
Cause: Two machines modified state concurrently
Solution:
-
If you know which is correct:
# Keep local heimdal state resolve --use-local # Or keep remote heimdal state resolve --use-remote
-
If both have valid changes:
heimdal state resolve --merge
-
If conflicts are complex:
heimdal state resolve --manual # Then edit ~/.heimdal/state.json
Symptom:
⚠️ Warning: Cannot reach remote repository: connection refused
Proceeding with local-only lock.
Remember to sync when connection is restored.
Cause: No internet connection or Git remote unavailable
Impact: Operations proceed with local lock only
Solution:
- This is normal when offline
- Sync when connection is restored:
heimdal sync
Symptom:
Modified Files (1):
• .bashrc
Expected: 5d41402abc4b2a76b9719d911017c592
Actual: 098f6bcd4621d373cade4e832627b4f6
Cause: File was modified outside of Heimdal
Solutions:
-
Accept the changes (keep modified version):
heimdal commit -m "Accept .bashrc changes" --push -
Restore from dotfiles (revert changes):
heimdal apply --force
Symptom:
Error: Failed to migrate state from V1 to V2: ...
Solutions:
-
Check backup exists:
ls ~/.heimdal/backups/ -
Restore from backup if needed:
cp ~/.heimdal/backups/state_v1_*.json ~/.heimdal/state.json
-
Try migration again with verbose output:
heimdal --verbose state migrate
-
Manual migration (last resort):
- View backup:
cat ~/.heimdal/backups/state_v1_*.json - Create V2 manually following the schema
- Validate:
heimdal state version
- View backup:
Symptom:
Error: Failed to read lock file: invalid JSON
Solution:
# Remove corrupted lock file
rm ~/.heimdal/state.lock
# Try operation again
heimdal applySymptom:
Merge conflicts detected in:
- state.json
Please resolve conflicts manually and run 'heimdal sync' again
Solution:
-
Check Git status:
cd ~/.dotfiles git status
-
Resolve Git conflicts:
# Edit conflicted files vim state.json # Mark as resolved git add state.json git commit -m "Resolve state conflicts"
-
Sync again:
heimdal sync
Create ~/.heimdal/lock_config.json:
{
"lock_type": "Hybrid",
"timeout": 600,
"detect_stale": true,
"retry": {
"attempts": 5,
"delay_seconds": 3,
"exponential_backoff": true
}
}# View lineage details
cat ~/.heimdal/state.json | jq '.lineage'Output:
{
"id": "lineage-abc123",
"serial": 42,
"parent_serial": 41,
"git_commit": "a1b2c3d4e5f6",
"machines": [
"machine-1",
"machine-2",
"machine-3"
]
}Enable verbose logging:
heimdal --verbose apply
heimdal --verbose sync
heimdal --verbose state check-conflictsManual backup:
cp ~/.heimdal/state.json ~/.heimdal/state.backup.jsonRestore backup:
cp ~/.heimdal/state.backup.json ~/.heimdal/state.json
heimdal state version # VerifyAutomatic backups are created:
- Before migration:
~/.heimdal/backups/state_v1_*.json - By default: Not implemented (use Git for versioning)
# After making changes
heimdal commit -m "Add new shell aliases"
git push# Before modifying dotfiles
heimdal sync
# Make changes...
heimdal apply
heimdal commit -m "Updated vim config" --push# Good
heimdal commit -m "Add tmux configuration for split pane navigation"
# Bad
heimdal commit -m "update"# Don't ignore conflicts
heimdal sync
# If conflicts detected:
heimdal state check-conflicts
heimdal state resolve --merge
git push# Regular checks
heimdal state version
heimdal state check-drift
heimdal state history --limit 5# Check version
heimdal --version
# Update (method depends on installation)
brew upgrade heimdal # macOS
cargo install --force heimdal # From source- Safety First: Locks prevent data loss from concurrent operations
- Offline-capable: Works without internet connection
- Git-native: Leverages Git's distributed nature
- Transparent: State file is readable JSON
- Recoverable: Backups and manual editing possible
| Feature | Heimdal | Terraform | Chezmoi | Stow |
|---|---|---|---|---|
| State Locking | ✅ Hybrid | ✅ Remote | ❌ None | ❌ None |
| Conflict Resolution | ✅ Yes | ✅ Yes | ❌ Manual | ❌ Manual |
| Multi-machine | ✅ Yes | ✅ Yes | ❌ No | |
| Versioning | ✅ Yes | ✅ Yes | ❌ No | ❌ No |
| Offline Support | ✅ Yes | ✅ Yes | ✅ Yes |
Potential improvements for future versions:
- Remote State Backend: Store state in cloud (S3, GCS, etc.)
- State Encryption: Encrypt sensitive state data
- Webhook Integration: Notify on state changes
- State Diff: View detailed state differences
- State Pruning: Remove old history entries
- Conflict Visualization: GUI for conflict resolution
- Documentation: https://github.com/limistah/heimdal/blob/main/STATE_MANAGEMENT.md
- Issues: https://github.com/limistah/heimdal/issues
- Discussions: https://github.com/limistah/heimdal/discussions
When reporting state management issues, include:
- State version:
heimdal state version - Lock status:
heimdal state lock-info - Recent history:
heimdal state history --limit 10 - Conflict details:
heimdal state check-conflicts - Verbose logs:
heimdal --verbose <command>
Contributions welcome! See CONTRIBUTING.md for details.
Version: 1.0.0
Last Updated: 2024-01-15
Author: Heimdal Team