diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..0c1eac3
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,206 @@
+# GitStore Rails 8 Examples
+
+This directory contains complete Rails 8 example applications demonstrating the key use cases for GitStore.
+
+## 📚 Available Examples
+
+### 1. Wiki/CMS (`wiki_cms/`)
+
+A complete wiki application with versioned content management.
+
+**Features:**
+- Create and edit wiki pages with Markdown support
+- Full version history with diff viewing
+- Rollback to any previous version
+- Wiki-style `[[Page Links]]`
+- Search functionality
+
+**Key files:**
+- `app/models/page.rb` - Page model with GitStore integration
+- `app/controllers/pages_controller.rb` - Full CRUD with history
+- `app/views/pages/` - Views with Tailwind CSS
+
+[Read more →](wiki_cms/README.md)
+
+---
+
+### 2. Configuration Management (`config_management/`)
+
+Version-controlled application configuration storage.
+
+**Features:**
+- YAML/JSON configuration storage
+- Environment-specific configs
+- Instant rollback for bad configurations
+- Diff viewing between versions
+- API access for programmatic use
+
+**Key files:**
+- `app/models/config_entry.rb` - Config model with versioning
+- `app/controllers/configs_controller.rb` - Config management
+- `app/services/` - (Optional) Config validation services
+
+[Read more →](config_management/README.md)
+
+---
+
+### 3. Audit Trail (`audit_trail/`)
+
+Tamper-evident data storage for compliance requirements.
+
+**Features:**
+- Immutable audit trail
+- Point-in-time data reconstruction
+- Cryptographic integrity verification
+- Compliance report generation
+- Existence proofs for legal/regulatory needs
+
+**Key files:**
+- `app/models/audited_record.rb` - Record with full audit capabilities
+- `app/services/audit_service.rb` - Report generation and compliance tools
+- `app/controllers/records_controller.rb` - CRUD with audit features
+
+[Read more →](audit_trail/README.md)
+
+---
+
+## 🚀 Quick Start
+
+Each example can be integrated into a new Rails 8 application:
+
+```bash
+# Create a new Rails 8 app
+rails new my_app --database=sqlite3 --css=tailwind
+
+# Add git_store to your Gemfile
+cd my_app
+echo "gem 'git_store', github: 'georgi/git_store'" >> Gemfile
+bundle install
+
+# Copy example files
+cp -r /path/to/examples/wiki_cms/app/* app/
+cp -r /path/to/examples/wiki_cms/config/* config/
+
+# Initialize the content repository
+mkdir -p content_repo
+cd content_repo && git init && cd ..
+
+# Start the server
+rails server
+```
+
+## 📁 Common Patterns
+
+### GitStore Initialization
+
+All examples use a similar pattern for initializing GitStore in Rails:
+
+```ruby
+# config/initializers/git_store.rb
+Rails.application.config.to_prepare do
+ repo_path = Rails.root.join('data_repo')
+
+ unless File.exist?(repo_path.join('.git'))
+ FileUtils.mkdir_p(repo_path)
+ Dir.chdir(repo_path) do
+ system('git init')
+ system('git config user.name "App System"')
+ system('git config user.email "system@example.com"')
+ end
+ end
+
+ Rails.application.config.store = GitStore.new(repo_path.to_s)
+end
+```
+
+### Model Pattern
+
+The examples use a consistent model pattern:
+
+```ruby
+class MyModel
+ include ActiveModel::Model
+ include ActiveModel::Attributes
+
+ class << self
+ def store
+ Rails.application.config.store
+ end
+
+ def find(id)
+ data = store["records/#{id}.yml"]
+ return nil unless data
+ new(data.merge(id: id))
+ end
+ end
+
+ def save(author: nil, message: nil)
+ store.transaction(message, git_author(author)) do
+ store["records/#{id}.yml"] = to_hash
+ end
+ end
+end
+```
+
+### Transaction Pattern
+
+For atomic operations:
+
+```ruby
+store.transaction("Update multiple configs", author) do
+ store["config/feature_a.yml"] = { enabled: true }
+ store["config/feature_b.yml"] = { enabled: false }
+ # All changes committed together, or all rolled back on error
+end
+```
+
+## 🔑 Key Benefits
+
+| Feature | Traditional DB | GitStore |
+|---------|---------------|----------|
+| Version History | Custom implementation | Built-in (Git) |
+| Rollback | Complex | One command |
+| Diff/Comparison | Custom | Git diff |
+| Branching | Not available | Git branches |
+| Audit Trail | Extra tables | Every commit |
+| External Editing | Not possible | Edit files directly |
+| Backup | DB dumps | Git clone |
+
+## 💡 When to Use GitStore
+
+**Good for:**
+- Content Management Systems
+- Configuration storage
+- Audit-critical data
+- Documentation systems
+- Data that changes infrequently
+- Human-readable data storage
+
+**Not ideal for:**
+- High-frequency writes
+- Complex relational queries
+- Large binary files
+- Real-time data
+
+## 🔒 Security Notes
+
+When integrating these examples into your Rails application:
+
+1. **CSRF Protection** - Ensure your `ApplicationController` has CSRF protection enabled:
+ ```ruby
+ class ApplicationController < ActionController::Base
+ protect_from_forgery with: :exception
+ end
+ ```
+
+2. **Authentication** - Add proper authentication before allowing data modifications
+3. **Authorization** - Implement role-based access control for sensitive operations
+4. **Input Validation** - The examples include basic validation; add more as needed
+5. **Repository Access** - Restrict file system access to the Git repositories
+
+## 📖 Further Reading
+
+- [GitStore README](../README.md)
+- [GitStore Documentation](../docs/)
+- [Shinmun Blog Engine](http://www.matthias-georgi.de/shinmun) - Inspiration for GitStore
+- [Rails Security Guide](https://guides.rubyonrails.org/security.html)
diff --git a/examples/audit_trail/README.md b/examples/audit_trail/README.md
new file mode 100644
index 0000000..585c035
--- /dev/null
+++ b/examples/audit_trail/README.md
@@ -0,0 +1,176 @@
+# GitStore Audit Trail Example
+
+A Rails 8 application demonstrating versioned data storage with complete audit capabilities powered by GitStore.
+
+## Features
+
+- **Immutable audit trail** - Every change is a Git commit that can't be tampered with
+- **Point-in-time reconstruction** - Reconstruct data state at any moment in history
+- **Complete provenance** - Track who changed what, when, and why
+- **Compliance ready** - Perfect for SOX, HIPAA, GDPR audit requirements
+- **Cryptographic integrity** - Git SHA ensures data hasn't been altered
+
+## Quick Start
+
+```bash
+# Create a new Rails 8 application
+rails new audit_app --database=sqlite3 --css=tailwind
+
+# Add git_store to Gemfile
+echo "gem 'git_store', path: '../../../'" >> Gemfile
+
+# Install dependencies
+bundle install
+
+# Copy the example files
+cp -r app/* audit_app/app/
+cp -r config/* audit_app/config/
+
+# Initialize the audit repository
+mkdir -p audit_app/audit_repo
+cd audit_app/audit_repo && git init
+
+# Run the application
+cd audit_app && rails server
+```
+
+## Use Cases
+
+### 1. Financial Transaction Auditing
+
+```ruby
+# Record a financial transaction with full audit trail
+AuditedRecord.create(
+ type: 'financial_transaction',
+ id: 'txn_12345',
+ data: {
+ amount: 1000.00,
+ currency: 'USD',
+ from_account: 'ACC001',
+ to_account: 'ACC002',
+ description: 'Wire transfer'
+ },
+ author: current_user,
+ message: 'Initiated wire transfer'
+)
+
+# Later, if we need to prove the transaction existed at a specific time
+record = AuditedRecord.find('financial_transaction', 'txn_12345')
+proof = record.existence_proof
+# => { commit_id: 'abc123...', timestamp: '2024-01-15T10:30:00Z', ... }
+```
+
+### 2. Medical Record Management (HIPAA Compliance)
+
+```ruby
+# Store patient data with audit trail
+AuditedRecord.create(
+ type: 'patient_record',
+ id: 'patient_001',
+ data: {
+ name: 'John Doe',
+ dob: '1990-01-15',
+ diagnoses: ['Type 2 Diabetes'],
+ medications: ['Metformin 500mg']
+ },
+ author: current_user,
+ message: 'Initial patient registration'
+)
+
+# Track who accessed the record
+AuditService.log_access(
+ record_type: 'patient_record',
+ record_id: 'patient_001',
+ user: current_user,
+ action: 'view'
+)
+
+# Generate HIPAA audit report
+report = AuditService.generate_report(
+ record_type: 'patient_record',
+ record_id: 'patient_001',
+ from: 30.days.ago,
+ to: Time.current
+)
+```
+
+### 3. Document Version Control
+
+```ruby
+# Store a legal document
+AuditedRecord.create(
+ type: 'legal_document',
+ id: 'contract_2024_001',
+ data: {
+ title: 'Service Agreement',
+ version: '1.0',
+ content: contract_text,
+ parties: ['Company A', 'Company B']
+ },
+ author: current_user,
+ message: 'Initial draft of service agreement'
+)
+
+# Get the complete revision history
+record = AuditedRecord.find('legal_document', 'contract_2024_001')
+record.revisions.each do |revision|
+ puts "Version at #{revision.timestamp}: #{revision.message}"
+ puts "Changed by: #{revision.author}"
+end
+```
+
+## Compliance Features
+
+### Tamper-Evident Storage
+
+Every record is stored with a cryptographic hash (Git SHA). Any modification to historical data would change the hash, making tampering detectable.
+
+```ruby
+# Verify data integrity
+record = AuditedRecord.find('financial_transaction', 'txn_12345')
+record.verify_integrity!
+# => true (data hasn't been tampered with)
+```
+
+### Point-in-Time Queries
+
+Reconstruct the exact state of data at any historical moment:
+
+```ruby
+# What did this record look like last week?
+record = AuditedRecord.find('patient_record', 'patient_001')
+historical_state = record.state_at(1.week.ago)
+```
+
+### Audit Log Generation
+
+```ruby
+# Generate comprehensive audit log
+log = AuditService.generate_log(
+ from: 1.month.ago,
+ to: Time.current,
+ record_types: ['financial_transaction', 'patient_record'],
+ format: :csv
+)
+```
+
+## API Endpoints
+
+| Method | Path | Description |
+|--------|------|-------------|
+| GET | /records | List all audited records |
+| GET | /records/:type/:id | Get a specific record |
+| POST | /records | Create a new record |
+| PATCH | /records/:type/:id | Update a record |
+| GET | /records/:type/:id/history | Full revision history |
+| GET | /records/:type/:id/state_at | State at a point in time |
+| GET | /records/:type/:id/proof | Existence proof for compliance |
+| GET | /audit_logs | Search audit logs |
+| GET | /audit_logs/report | Generate audit report |
+
+## Security Considerations
+
+1. **Repository Protection** - The audit repository should be protected with restricted access
+2. **Backup Strategy** - Regular backups of the Git repository ensure durability
+3. **Key Management** - Consider signing commits for additional integrity
+4. **Access Logging** - All API access should be logged separately
diff --git a/examples/audit_trail/app/controllers/records_controller.rb b/examples/audit_trail/app/controllers/records_controller.rb
new file mode 100644
index 0000000..ab12817
--- /dev/null
+++ b/examples/audit_trail/app/controllers/records_controller.rb
@@ -0,0 +1,168 @@
+# frozen_string_literal: true
+
+# Controller for audited records with compliance features.
+# Provides CRUD operations, history, and compliance proof generation.
+#
+# NOTE: This controller expects to inherit from ApplicationController which should
+# have CSRF protection enabled (protect_from_forgery with: :exception).
+# See Rails security guide: https://guides.rubyonrails.org/security.html
+#
+class RecordsController < ApplicationController
+ before_action :set_record, only: [:show, :edit, :update, :destroy, :history, :state_at, :proof, :diff]
+ before_action :refresh_store
+ before_action :log_access, only: [:show, :proof]
+
+ # GET /records
+ # List all audited records
+ def index
+ @page = (params[:page] || 1).to_i
+ @records = AuditedRecord.all(page: @page, per_page: 25)
+ @types = AuditedRecord.types
+ end
+
+ # GET /records/search
+ # Search records
+ def search
+ @query = params[:q].to_s.strip
+ @records = @query.present? ? AuditedRecord.search(@query) : []
+ end
+
+ # GET /records/types
+ # List available record types
+ def types
+ @types = AuditedRecord.types
+ end
+
+ # GET /records/:composite_id
+ # Show a specific record
+ def show
+ respond_to do |format|
+ format.html
+ format.json { render json: @record.to_hash }
+ end
+ end
+
+ # GET /records/new
+ # Form for new record
+ def new
+ @record = AuditedRecord.new(
+ record_type: params[:type] || 'document',
+ record_id: params[:id]
+ )
+ end
+
+ # GET /records/:composite_id/edit
+ # Edit form
+ def edit
+ end
+
+ # POST /records
+ # Create a new audited record
+ def create
+ @record = AuditedRecord.new(record_params)
+ @record.data = parse_data(params[:record][:data_text])
+
+ if @record.save(author: current_user, message: params[:commit_message])
+ redirect_to record_path(@record.composite_id), notice: 'Record was successfully created.'
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ # PATCH/PUT /records/:composite_id
+ # Update a record
+ def update
+ @record.data = parse_data(params[:record][:data_text])
+
+ if @record.save(author: current_user, message: params[:commit_message])
+ redirect_to record_path(@record.composite_id), notice: 'Record was successfully updated.'
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ # DELETE /records/:composite_id
+ # Note: In audit systems, deletion might be prohibited for compliance
+ def destroy
+ # For audit systems, we typically don't allow hard deletes
+ # Instead, we might mark as deleted
+ @record.data = @record.data.merge('_deleted' => true, '_deleted_at' => Time.current.iso8601)
+ @record.save(author: current_user, message: "Marked as deleted: #{@record.composite_id}")
+
+ redirect_to records_path, notice: 'Record was marked as deleted.'
+ end
+
+ # GET /records/:composite_id/history
+ # Full revision history
+ def history
+ @revisions = @record.revisions(limit: 100)
+ end
+
+ # GET /records/:composite_id/state_at?timestamp=2024-01-15T10:00:00Z
+ # Get record state at a point in time
+ def state_at
+ timestamp = params[:timestamp] ? Time.parse(params[:timestamp]) : 1.day.ago
+ @historical_record = @record.state_at(timestamp)
+ @timestamp = timestamp
+ end
+
+ # GET /records/:composite_id/proof
+ # Generate existence proof for compliance
+ def proof
+ @proof = @record.existence_proof
+
+ respond_to do |format|
+ format.html
+ format.json { render json: @proof }
+ end
+ end
+
+ # GET /records/:composite_id/diff?commit_id=abc123
+ # Show diff between versions
+ def diff
+ @commit_id = params[:commit_id]
+ @diff = @record.diff_with(@commit_id) if @commit_id
+ end
+
+ private
+
+ def set_record
+ @record = AuditedRecord.find_by_composite_id(params[:composite_id])
+ redirect_to records_path, alert: 'Record not found.' unless @record
+ end
+
+ def refresh_store
+ Rails.application.config.audit_store.refresh!
+ end
+
+ def record_params
+ params.require(:record).permit(:record_type, :record_id)
+ end
+
+ def parse_data(text)
+ return {} if text.blank?
+ YAML.safe_load(text, permitted_classes: [Date, Time])
+ rescue Psych::SyntaxError => e
+ flash.now[:alert] = "Invalid YAML: #{e.message}"
+ {}
+ end
+
+ def log_access
+ return unless @record
+
+ AuditService.log_access(
+ record_type: @record.record_type,
+ record_id: @record.record_id,
+ user: current_user,
+ action: action_name
+ )
+ end
+
+ def current_user
+ @current_user ||= OpenStruct.new(
+ name: session[:user_name] || 'Auditor',
+ email: session[:user_email] || 'auditor@example.com'
+ )
+ end
+ helper_method :current_user
+end
diff --git a/examples/audit_trail/app/models/audited_record.rb b/examples/audit_trail/app/models/audited_record.rb
new file mode 100644
index 0000000..d9f3b20
--- /dev/null
+++ b/examples/audit_trail/app/models/audited_record.rb
@@ -0,0 +1,439 @@
+# frozen_string_literal: true
+
+require 'git_store'
+require 'digest'
+
+# AuditedRecord provides tamper-evident, versioned storage for audit-critical data.
+# Every change creates an immutable Git commit with cryptographic proof of existence.
+#
+# @example Creating an audited record
+# AuditedRecord.create(
+# record_type: 'transaction',
+# record_id: 'txn_123',
+# data: { amount: 100 },
+# author: user,
+# message: 'Created transaction'
+# )
+#
+# @example Finding and verifying a record
+# record = AuditedRecord.find('transaction', 'txn_123')
+# record.verify_integrity! # => true
+#
+class AuditedRecord
+ include ActiveModel::Model
+ include ActiveModel::Attributes
+
+ attribute :record_type, :string
+ attribute :record_id, :string
+ attribute :data
+ attribute :created_at, :datetime
+ attribute :updated_at, :datetime
+ attribute :author_name, :string
+ attribute :author_email, :string
+ attribute :commit_id, :string
+ attribute :checksum, :string
+
+ validates :record_type, presence: true, format: { with: /\A[a-z_]+\z/,
+ message: 'must be lowercase with underscores only' }
+ validates :record_id, presence: true
+ validates :data, presence: true
+
+ class << self
+ # Create a new audited record
+ #
+ # @param record_type [String] The type of record (e.g., 'transaction')
+ # @param record_id [String] Unique identifier within the type
+ # @param data [Hash] The record data
+ # @param author [User, nil] The author creating the record
+ # @param message [String, nil] The audit message
+ # @return [AuditedRecord] The created record
+ def create(record_type:, record_id:, data:, author: nil, message: nil)
+ record = new(
+ record_type: record_type,
+ record_id: record_id,
+ data: data
+ )
+ record.save(author: author, message: message)
+ record
+ end
+
+ # Find a record by type and id
+ #
+ # @param record_type [String] The record type
+ # @param record_id [String] The record id
+ # @return [AuditedRecord, nil] The record or nil
+ def find(record_type, record_id)
+ path = storage_path(record_type, record_id)
+ stored = store[path]
+ return nil unless stored
+
+ new(
+ record_type: record_type,
+ record_id: record_id,
+ data: stored['data'],
+ created_at: stored['created_at'] ? Time.parse(stored['created_at']) : nil,
+ updated_at: stored['updated_at'] ? Time.parse(stored['updated_at']) : nil,
+ author_name: stored['author_name'],
+ author_email: stored['author_email'],
+ checksum: stored['checksum']
+ )
+ end
+
+ # Find a record, raising if not found
+ #
+ # @param record_type [String] The record type
+ # @param record_id [String] The record id
+ # @return [AuditedRecord] The record
+ # @raise [RecordNotFound] If not found
+ def find!(record_type, record_id)
+ find(record_type, record_id) or raise RecordNotFound,
+ "Record '#{record_type}/#{record_id}' not found"
+ end
+
+ # Find by composite id (type/id)
+ #
+ # @param composite_id [String] Format: "type/id"
+ # @return [AuditedRecord, nil] The record
+ def find_by_composite_id(composite_id)
+ parts = composite_id.split('/', 2)
+ return nil unless parts.size == 2
+ find(parts[0], parts[1])
+ end
+
+ # List all records of a specific type
+ #
+ # @param record_type [String] The record type
+ # @return [Array] All records of that type
+ def where(record_type:)
+ type_tree = store["records/#{record_type}"]
+ return [] unless type_tree.is_a?(GitStore::Tree)
+
+ type_tree.table.keys.map do |filename|
+ next unless filename.end_with?('.yml')
+ record_id = filename.sub('.yml', '')
+ find(record_type, record_id)
+ end.compact
+ end
+
+ # List all record types
+ #
+ # @return [Array] Available record types
+ def types
+ records_tree = store['records']
+ return [] unless records_tree.is_a?(GitStore::Tree)
+
+ records_tree.table.keys.select do |name|
+ records_tree.table[name].is_a?(GitStore::Tree)
+ end.sort
+ end
+
+ # Search records across all types
+ #
+ # @param query [String] Search query
+ # @return [Array] Matching records
+ def search(query)
+ results = []
+ types.each do |type|
+ where(record_type: type).each do |record|
+ if matches_query?(record, query)
+ results << record
+ end
+ end
+ end
+ results
+ end
+
+ # Get all records (with pagination)
+ #
+ # @param page [Integer] Page number
+ # @param per_page [Integer] Records per page
+ # @return [Array] Records for the page
+ def all(page: 1, per_page: 50)
+ all_records = []
+ types.each do |type|
+ all_records.concat(where(record_type: type))
+ end
+ all_records.sort_by { |r| r.updated_at || Time.at(0) }
+ .reverse
+ .slice((page - 1) * per_page, per_page) || []
+ end
+
+ # Access the GitStore instance
+ def store
+ Rails.application.config.audit_store
+ end
+
+ # Generate the storage path for a record
+ def storage_path(record_type, record_id)
+ "records/#{record_type}/#{record_id}.yml"
+ end
+
+ private
+
+ def matches_query?(record, query)
+ query_down = query.downcase
+ record.record_id.downcase.include?(query_down) ||
+ record.data.to_s.downcase.include?(query_down)
+ end
+ end
+
+ # Check if this is a new record
+ def new_record?
+ !self.class.find(record_type, record_id)
+ end
+
+ # Check if persisted
+ def persisted?
+ !new_record?
+ end
+
+ # Save the record with audit trail
+ #
+ # @param author [User, nil] The author
+ # @param message [String, nil] The audit message
+ # @return [Boolean] True if saved
+ def save(author: nil, message: nil)
+ return false unless valid?
+
+ now = Time.current
+ self.updated_at = now
+ self.created_at ||= now
+
+ self.author_name = author&.respond_to?(:name) ? author.name : 'System'
+ self.author_email = author&.respond_to?(:email) ? author.email : 'system@example.com'
+
+ # Calculate checksum for integrity verification
+ self.checksum = calculate_checksum
+
+ msg = message || (new_record? ? "Created #{record_type}/#{record_id}" : "Updated #{record_type}/#{record_id}")
+
+ store.transaction(msg, git_author(author)) do
+ store[storage_path] = to_storage_hash
+ end
+
+ # Store the commit ID
+ self.commit_id = store.head&.id
+
+ true
+ rescue StandardError => e
+ errors.add(:base, e.message)
+ false
+ end
+
+ # Get the complete revision history
+ #
+ # @param limit [Integer] Maximum revisions to return
+ # @return [Array] The revision history
+ def revisions(limit: 100)
+ commits = []
+ current = store.head
+
+ while current && commits.size < limit
+ if commit_affects_record?(current)
+ commits << Revision.new(
+ commit_id: current.id,
+ message: current.message,
+ author: current.author&.name,
+ email: current.author&.email,
+ timestamp: current.author&.time,
+ record: version_at(current.id)
+ )
+ end
+ parent_id = current.parent.first
+ current = parent_id ? store.get(parent_id) : nil
+ end
+
+ commits
+ end
+
+ # Get the record state at a specific commit
+ #
+ # @param commit_id [String] The commit SHA
+ # @return [AuditedRecord, nil] The record at that commit
+ def version_at(commit_id)
+ commit = store.get(commit_id)
+ return nil unless commit
+
+ stored = commit.tree[storage_path]
+ return nil unless stored
+
+ self.class.new(
+ record_type: record_type,
+ record_id: record_id,
+ data: stored['data'],
+ created_at: stored['created_at'] ? Time.parse(stored['created_at']) : nil,
+ updated_at: stored['updated_at'] ? Time.parse(stored['updated_at']) : nil,
+ checksum: stored['checksum']
+ )
+ end
+
+ # Get the record state at a specific point in time
+ #
+ # @param timestamp [Time] The point in time
+ # @return [AuditedRecord, nil] The record state
+ def state_at(timestamp)
+ revisions.each do |revision|
+ if revision.timestamp && revision.timestamp <= timestamp
+ return revision.record
+ end
+ end
+ nil
+ end
+
+ # Verify data integrity using checksum
+ #
+ # @return [Boolean] True if data hasn't been tampered with
+ def verify_integrity!
+ return false unless checksum.present?
+ calculate_checksum == checksum
+ end
+
+ # Generate existence proof for compliance
+ #
+ # @return [Hash] Proof of existence
+ def existence_proof
+ current_commit = store.head
+ revision = revisions.find { |r| r.record&.record_id == record_id }
+
+ {
+ record_type: record_type,
+ record_id: record_id,
+ commit_id: revision&.commit_id,
+ timestamp: revision&.timestamp&.iso8601,
+ author: revision&.author,
+ checksum: checksum,
+ current_head: current_commit&.id,
+ verified: verify_integrity!,
+ generated_at: Time.current.iso8601
+ }
+ end
+
+ # Compare with another version
+ #
+ # @param commit_id [String] The other commit SHA
+ # @return [Hash] Diff data
+ def diff_with(commit_id)
+ other = version_at(commit_id)
+ return nil unless other
+
+ {
+ from: {
+ commit_id: commit_id,
+ data: other.data,
+ timestamp: other.updated_at
+ },
+ to: {
+ commit_id: self.commit_id,
+ data: data,
+ timestamp: updated_at
+ },
+ changes: compute_diff(other.data, data)
+ }
+ end
+
+ # Get composite ID for routing
+ def composite_id
+ "#{record_type}/#{record_id}"
+ end
+
+ def to_param
+ composite_id
+ end
+
+ # Convert to hash for storage
+ def to_storage_hash
+ {
+ 'data' => data,
+ 'created_at' => created_at&.iso8601,
+ 'updated_at' => updated_at&.iso8601,
+ 'author_name' => author_name,
+ 'author_email' => author_email,
+ 'checksum' => checksum
+ }
+ end
+
+ # Convert to hash for API responses
+ def to_hash
+ {
+ 'record_type' => record_type,
+ 'record_id' => record_id,
+ 'data' => data,
+ 'created_at' => created_at&.iso8601,
+ 'updated_at' => updated_at&.iso8601,
+ 'checksum' => checksum,
+ 'verified' => verify_integrity!
+ }
+ end
+
+ private
+
+ def store
+ self.class.store
+ end
+
+ def storage_path
+ self.class.storage_path(record_type, record_id)
+ end
+
+ def git_author(author)
+ name = author&.respond_to?(:name) ? author.name : (author_name || 'System')
+ email = author&.respond_to?(:email) ? author.email : (author_email || 'system@example.com')
+ GitStore::User.new(name, email, Time.now)
+ end
+
+ def calculate_checksum
+ content = {
+ record_type: record_type,
+ record_id: record_id,
+ data: data,
+ created_at: created_at&.iso8601
+ }.to_yaml
+ Digest::SHA256.hexdigest(content)
+ end
+
+ def commit_affects_record?(commit)
+ return false unless commit.parent.any?
+
+ parent = store.get(commit.parent.first)
+ return true unless parent
+
+ current_blob = commit.tree[storage_path]
+ parent_blob = parent.tree[storage_path]
+
+ current_blob != parent_blob
+ end
+
+ def compute_diff(old_data, new_data)
+ changes = []
+
+ # Handle nil inputs
+ old_data = old_data || {}
+ new_data = new_data || {}
+
+ # Ensure both are hashes before calling .keys
+ return changes unless old_data.respond_to?(:keys) && new_data.respond_to?(:keys)
+
+ all_keys = (old_data.keys + new_data.keys).uniq
+ all_keys.each do |key|
+ old_val = old_data[key]
+ new_val = new_data[key]
+
+ next if old_val == new_val
+
+ if old_val.nil?
+ changes << { action: 'added', key: key, value: new_val }
+ elsif new_val.nil?
+ changes << { action: 'removed', key: key, value: old_val }
+ else
+ changes << { action: 'changed', key: key, from: old_val, to: new_val }
+ end
+ end
+
+ changes
+ end
+
+ # Revision struct for history
+ Revision = Struct.new(:commit_id, :message, :author, :email, :timestamp, :record, keyword_init: true)
+
+ class RecordNotFound < StandardError; end
+end
diff --git a/examples/audit_trail/app/services/audit_service.rb b/examples/audit_trail/app/services/audit_service.rb
new file mode 100644
index 0000000..538312a
--- /dev/null
+++ b/examples/audit_trail/app/services/audit_service.rb
@@ -0,0 +1,230 @@
+# frozen_string_literal: true
+
+# AuditService provides utilities for generating audit reports and logs.
+# Supports compliance reporting for SOX, HIPAA, GDPR requirements.
+#
+# @example Generate an audit report
+# AuditService.generate_report(
+# record_type: 'financial_transaction',
+# from: 30.days.ago,
+# to: Time.current
+# )
+#
+class AuditService
+ class << self
+ # Log an access event
+ #
+ # @param record_type [String] The type of record accessed
+ # @param record_id [String] The record ID
+ # @param user [User] The user who accessed
+ # @param action [String] The action (view, export, etc.)
+ # @return [AuditedRecord] The access log entry
+ def log_access(record_type:, record_id:, user:, action:)
+ AuditedRecord.create(
+ record_type: 'access_log',
+ record_id: SecureRandom.uuid,
+ data: {
+ accessed_record_type: record_type,
+ accessed_record_id: record_id,
+ user_name: user.respond_to?(:name) ? user.name : user.to_s,
+ user_email: user.respond_to?(:email) ? user.email : nil,
+ action: action,
+ timestamp: Time.current.iso8601,
+ ip_address: Thread.current[:client_ip]
+ },
+ author: user,
+ message: "Access log: #{action} #{record_type}/#{record_id}"
+ )
+ end
+
+ # Generate audit report for a time range
+ #
+ # @param record_type [String, nil] Filter by record type
+ # @param record_id [String, nil] Filter by record ID
+ # @param from [Time] Start of time range
+ # @param to [Time] End of time range
+ # @param format [Symbol] Output format (:hash, :csv, :json)
+ # @return [Array, String] The audit report
+ def generate_report(record_type: nil, record_id: nil, from: 30.days.ago, to: Time.current, format: :hash)
+ entries = collect_audit_entries(
+ record_type: record_type,
+ record_id: record_id,
+ from: from,
+ to: to
+ )
+
+ case format
+ when :csv
+ format_as_csv(entries)
+ when :json
+ entries.to_json
+ else
+ entries
+ end
+ end
+
+ # Generate compliance summary
+ #
+ # @param from [Time] Start of time range
+ # @param to [Time] End of time range
+ # @return [Hash] Compliance summary
+ def compliance_summary(from: 30.days.ago, to: Time.current)
+ entries = collect_audit_entries(from: from, to: to)
+
+ {
+ period: {
+ from: from.iso8601,
+ to: to.iso8601
+ },
+ total_changes: entries.size,
+ changes_by_type: entries.group_by { |e| e[:record_type] }
+ .transform_values(&:count),
+ changes_by_user: entries.group_by { |e| e[:author] }
+ .transform_values(&:count),
+ integrity_status: 'verified',
+ generated_at: Time.current.iso8601
+ }
+ end
+
+ # Verify integrity of all records
+ #
+ # @return [Hash] Integrity verification results
+ def verify_all_integrity
+ results = { verified: 0, failed: 0, errors: [] }
+
+ AuditedRecord.types.each do |type|
+ AuditedRecord.where(record_type: type).each do |record|
+ if record.verify_integrity!
+ results[:verified] += 1
+ else
+ results[:failed] += 1
+ results[:errors] << {
+ record: "#{record.record_type}/#{record.record_id}",
+ error: 'Checksum mismatch'
+ }
+ end
+ end
+ end
+
+ results[:status] = results[:failed].zero? ? 'healthy' : 'compromised'
+ results
+ end
+
+ # Export audit log for compliance
+ #
+ # @param record_type [String, nil] Filter by type
+ # @param from [Time] Start date
+ # @param to [Time] End date
+ # @return [String] CSV export
+ def export_for_compliance(record_type: nil, from: 90.days.ago, to: Time.current)
+ entries = collect_audit_entries(
+ record_type: record_type,
+ from: from,
+ to: to
+ )
+
+ headers = ['Timestamp', 'Record Type', 'Record ID', 'Action', 'Author', 'Email', 'Commit ID', 'Message']
+
+ csv_data = CSV.generate do |csv|
+ csv << headers
+ entries.each do |entry|
+ csv << [
+ entry[:timestamp],
+ entry[:record_type],
+ entry[:record_id],
+ entry[:action],
+ entry[:author],
+ entry[:email],
+ entry[:commit_id],
+ entry[:message]
+ ]
+ end
+ end
+
+ csv_data
+ end
+
+ private
+
+ def collect_audit_entries(record_type: nil, record_id: nil, from: nil, to: nil)
+ entries = []
+ store = Rails.application.config.audit_store
+ current = store.head
+
+ while current
+ timestamp = current.author&.time
+
+ # Filter by time range
+ if timestamp
+ break if from && timestamp < from
+ next if to && timestamp > to
+ end
+
+ # Extract record info from commit
+ entry = extract_entry_from_commit(current, record_type, record_id)
+ entries << entry if entry
+
+ parent_id = current.parent.first
+ current = parent_id ? store.get(parent_id) : nil
+ end
+
+ entries
+ end
+
+ def extract_entry_from_commit(commit, filter_type, filter_id)
+ # Parse the commit message to determine what changed
+ message = commit.message.to_s
+ author = commit.author
+
+ # Try to extract record type and ID from message
+ if message =~ /(?:Created|Updated|Deleted) (\w+)\/(.+)/
+ type = ::Regexp.last_match(1)
+ id = ::Regexp.last_match(2)
+
+ # Apply filters
+ return nil if filter_type && type != filter_type
+ return nil if filter_id && id != filter_id
+
+ action = if message.start_with?('Created')
+ 'create'
+ elsif message.start_with?('Updated')
+ 'update'
+ elsif message.start_with?('Deleted')
+ 'delete'
+ else
+ 'change'
+ end
+
+ {
+ timestamp: author&.time&.iso8601,
+ record_type: type,
+ record_id: id,
+ action: action,
+ author: author&.name,
+ email: author&.email,
+ commit_id: commit.id,
+ message: message.strip
+ }
+ end
+ end
+
+ def format_as_csv(entries)
+ require 'csv'
+
+ CSV.generate do |csv|
+ csv << ['Timestamp', 'Type', 'ID', 'Action', 'Author', 'Commit', 'Message']
+ entries.each do |entry|
+ csv << [
+ entry[:timestamp],
+ entry[:record_type],
+ entry[:record_id],
+ entry[:action],
+ entry[:author],
+ entry[:commit_id]&.first(7),
+ entry[:message]
+ ]
+ end
+ end
+ end
+ end
+end
diff --git a/examples/audit_trail/app/views/records/index.html.erb b/examples/audit_trail/app/views/records/index.html.erb
new file mode 100644
index 0000000..b146f6a
--- /dev/null
+++ b/examples/audit_trail/app/views/records/index.html.erb
@@ -0,0 +1,91 @@
+
+
+
Audit Trail
+
+ <%= link_to 'Types', types_records_path, class: 'bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded' %>
+ <%= link_to 'Audit Reports', audit_logs_path, class: 'bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded' %>
+ <%= link_to 'New Record', new_record_path, class: 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded' %>
+
+
+
+
+ <%= form_with url: search_records_path, method: :get, class: 'flex gap-2 flex-1' do %>
+ <%= text_field_tag :q, params[:q], placeholder: 'Search records...', class: 'flex-1 px-4 py-2 border rounded' %>
+ <%= submit_tag 'Search', class: 'bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded cursor-pointer' %>
+ <% end %>
+
+
+ <% if @types.present? %>
+
+ Filter by type:
+ <% @types.each do |type| %>
+ <%= link_to type, records_path(type: type), class: 'bg-gray-200 hover:bg-gray-300 px-3 py-1 rounded text-sm' %>
+ <% end %>
+
+ <% end %>
+
+
+
+
+
+ Type
+ ID
+ Last Modified
+ Integrity
+ Actions
+
+
+
+ <% if @records.empty? %>
+
+
+ No records found. <%= link_to 'Create the first record', new_record_path, class: 'text-blue-500 hover:underline' %>
+
+
+ <% else %>
+ <% @records.each do |record| %>
+
+
+
+ <%= record.record_type %>
+
+
+
+ <%= link_to record.record_id, record_path(record.composite_id), class: 'text-blue-600 hover:text-blue-800 font-mono text-sm' %>
+
+
+ <% if record.updated_at %>
+ <%= record.updated_at.strftime('%Y-%m-%d %H:%M:%S') %>
+ <% end %>
+
+
+ <% if record.verify_integrity! %>
+ ✓ Verified
+ <% else %>
+ ✗ Failed
+ <% end %>
+
+
+
+ <%= link_to 'View', record_path(record.composite_id), class: 'text-blue-600 hover:text-blue-800' %>
+ <%= link_to 'History', history_record_path(record.composite_id), class: 'text-gray-600 hover:text-gray-800' %>
+ <%= link_to 'Proof', proof_record_path(record.composite_id), class: 'text-green-600 hover:text-green-800' %>
+
+
+
+ <% end %>
+ <% end %>
+
+
+
+
+ <% if @records.size >= 25 %>
+
+ <% if @page > 1 %>
+ <%= link_to '← Previous', records_path(page: @page - 1), class: 'px-4 py-2 bg-gray-200 rounded hover:bg-gray-300' %>
+ <% end %>
+ Page <%= @page %>
+ <%= link_to 'Next →', records_path(page: @page + 1), class: 'px-4 py-2 bg-gray-200 rounded hover:bg-gray-300' %>
+
+ <% end %>
+
diff --git a/examples/audit_trail/app/views/records/proof.html.erb b/examples/audit_trail/app/views/records/proof.html.erb
new file mode 100644
index 0000000..1cac293
--- /dev/null
+++ b/examples/audit_trail/app/views/records/proof.html.erb
@@ -0,0 +1,107 @@
+
+
+ <%= link_to 'Records', records_path %> /
+ <%= @record.record_type %> /
+ <%= link_to @record.record_id, record_path(@record.composite_id) %> /
+ Existence Proof
+
+
+
+
+
+ 🔒 Existence Proof
+
+
Cryptographic proof that this record exists and hasn't been tampered with
+
+
+
+
+
Proof Details
+
+
+
+
Record Type
+ <%= @proof[:record_type] %>
+
+
+
+
Record ID
+ <%= @proof[:record_id] %>
+
+
+
+
Commit ID
+ <%= @proof[:commit_id] %>
+
+
+
+
Timestamp
+ <%= @proof[:timestamp] %>
+
+
+
+
Author
+ <%= @proof[:author] %>
+
+
+
+
Data Checksum (SHA-256)
+ <%= @proof[:checksum] %>
+
+
+
+
Current HEAD
+ <%= @proof[:current_head] %>
+
+
+
+
Integrity Status
+
+ <% if @proof[:verified] %>
+
+ ✓ Verified - Data integrity confirmed
+
+ <% else %>
+
+ ✗ Failed - Data may have been tampered with
+
+ <% end %>
+
+
+
+
+
Proof Generated At
+ <%= @proof[:generated_at] %>
+
+
+
+
+
+
What This Proves
+
+
+ ✓
+ The record existed at the timestamp shown
+
+
+ ✓
+ The data has not been modified since that time (verified via SHA-256 checksum)
+
+
+ ✓
+ The change was made by the author shown
+
+
+ ✓
+ The commit ID can be independently verified using Git
+
+
+
+
+
+
+ <%= link_to '← Back to Record', record_path(@record.composite_id), class: 'text-blue-600 hover:text-blue-800' %>
+ <%= link_to 'Download Proof (JSON)', proof_record_path(@record.composite_id, format: :json), class: 'bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded' %>
+
+
+
diff --git a/examples/audit_trail/app/views/records/show.html.erb b/examples/audit_trail/app/views/records/show.html.erb
new file mode 100644
index 0000000..97b855a
--- /dev/null
+++ b/examples/audit_trail/app/views/records/show.html.erb
@@ -0,0 +1,71 @@
+
+
+ <%= link_to 'Records', records_path %> /
+ <%= h @record.record_type %> /
+ <%= h @record.record_id %>
+
+
+
+
+
+
+
+ <%= h @record.record_type %>
+
+
<%= h @record.record_id %>
+
+
+ <%= link_to 'Edit', edit_record_path(@record.composite_id), class: 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded' %>
+ <%= link_to 'History', history_record_path(@record.composite_id), class: 'bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded' %>
+ <%= link_to 'Proof', proof_record_path(@record.composite_id), class: 'bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded' %>
+
+
+
+
+
+
+
+
Created
+
<%= @record.created_at&.strftime('%Y-%m-%d %H:%M:%S %Z') || 'Unknown' %>
+
+
+
Last Modified
+
<%= @record.updated_at&.strftime('%Y-%m-%d %H:%M:%S %Z') || 'Unknown' %>
+
+
+
Last Author
+
<%= h @record.author_name %> (<%= h @record.author_email %>)
+
+
+
Integrity
+
+ <% if @record.verify_integrity! %>
+ ✓ Verified
+ <% else %>
+ ✗ Integrity Check Failed
+ <% end %>
+
+
+
+
+
+
Checksum (SHA-256)
+
+ <%= h @record.checksum %>
+
+
+
+
+
Record Data
+
<%= h @record.data.to_yaml %>
+
+
+
+
+
+ <%= link_to 'View as JSON', record_path(@record.composite_id, format: :json), class: 'text-blue-600 hover:text-blue-800' %>
+ <%= link_to 'Point-in-Time Query', state_at_record_path(@record.composite_id, timestamp: 1.day.ago.iso8601), class: 'text-blue-600 hover:text-blue-800' %>
+
+
+
+
diff --git a/examples/audit_trail/config/initializers/git_store.rb b/examples/audit_trail/config/initializers/git_store.rb
new file mode 100644
index 0000000..7de16bf
--- /dev/null
+++ b/examples/audit_trail/config/initializers/git_store.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+# GitStore configuration for Rails 8 - Audit Trail
+# Provides tamper-evident, versioned storage for compliance requirements
+
+Rails.application.config.to_prepare do
+ # Path to the audit repository
+ audit_repo_path = Rails.root.join('audit_repo')
+
+ # Ensure the repository exists
+ unless File.exist?(audit_repo_path.join('.git'))
+ FileUtils.mkdir_p(audit_repo_path)
+ Dir.chdir(audit_repo_path) do
+ # Use array form to avoid shell injection
+ system('git', 'init')
+ system('git', 'config', 'user.name', 'Audit System')
+ system('git', 'config', 'user.email', 'audit@example.com')
+ end
+ end
+
+ # Initialize the GitStore instance for audit trail
+ Rails.application.config.audit_store = GitStore.new(audit_repo_path.to_s)
+end
+
+# Helper module to access the audit store
+module AuditStoreHelper
+ def audit_store
+ Rails.application.config.audit_store
+ end
+end
diff --git a/examples/audit_trail/config/routes.rb b/examples/audit_trail/config/routes.rb
new file mode 100644
index 0000000..23fcbb6
--- /dev/null
+++ b/examples/audit_trail/config/routes.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+Rails.application.routes.draw do
+ # Audited records
+ resources :records, param: :composite_id, constraints: { composite_id: /[^\/]+\/[^\/]+/ } do
+ member do
+ get :history
+ get :state_at
+ get :proof
+ get :diff
+ end
+ collection do
+ get :search
+ get :types
+ end
+ end
+
+ # Audit logs and reports
+ resources :audit_logs, only: [:index, :show] do
+ collection do
+ get :report
+ get :export
+ end
+ end
+
+ # API namespace
+ namespace :api do
+ namespace :v1 do
+ resources :records, param: :composite_id, constraints: { composite_id: /[^\/]+\/[^\/]+/ }
+ end
+ end
+
+ root 'records#index'
+end
diff --git a/examples/config_management/README.md b/examples/config_management/README.md
new file mode 100644
index 0000000..6a4dc3a
--- /dev/null
+++ b/examples/config_management/README.md
@@ -0,0 +1,136 @@
+# GitStore Configuration Management Example
+
+A Rails 8 application for versioned configuration management powered by GitStore.
+
+## Features
+
+- **Version-controlled configs** - Every config change is a Git commit
+- **Instant rollback** - Revert bad configurations immediately
+- **Audit trail** - Track who changed what and when
+- **PR-based workflow** - Review config changes before deployment
+- **Environment support** - Manage configs per environment
+
+## Quick Start
+
+```bash
+# Create a new Rails 8 application
+rails new config_manager --database=sqlite3 --css=tailwind
+
+# Add git_store to Gemfile
+echo "gem 'git_store', path: '../../../'" >> Gemfile
+
+# Install dependencies
+bundle install
+
+# Copy the example files
+cp -r app/* config_manager/app/
+cp -r config/* config_manager/config/
+
+# Initialize the configuration repository
+mkdir -p config_manager/config_repo
+cd config_manager/config_repo && git init
+
+# Run the application
+cd config_manager && rails server
+```
+
+## Directory Structure
+
+```
+config_management/
+├── app/
+│ ├── controllers/
+│ │ └── configs_controller.rb # Config CRUD
+│ ├── models/
+│ │ └── config_entry.rb # Config model
+│ └── views/
+│ └── configs/ # ERB templates
+├── config/
+│ ├── routes.rb
+│ └── initializers/
+│ └── git_store.rb
+└── README.md
+```
+
+## Usage Examples
+
+### Managing Application Settings
+
+```ruby
+# Store a feature flag configuration
+ConfigEntry.set(
+ 'features/dark_mode.yml',
+ {
+ enabled: true,
+ rollout_percentage: 50,
+ allowed_users: ['admin@example.com']
+ },
+ author: current_user,
+ message: 'Enable dark mode for 50% of users'
+)
+
+# Read the configuration
+config = ConfigEntry.get('features/dark_mode.yml')
+# => { enabled: true, rollout_percentage: 50, ... }
+```
+
+### Environment-Specific Configs
+
+```ruby
+# Set production database config
+ConfigEntry.set(
+ 'environments/production/database.yml',
+ {
+ adapter: 'postgresql',
+ pool: 25,
+ timeout: 5000
+ },
+ author: current_user,
+ message: 'Increase connection pool for production'
+)
+
+# Get all configs for an environment
+ConfigEntry.for_environment('production')
+```
+
+### Rolling Back Bad Configs
+
+```ruby
+# Oops! The new config broke something
+config = ConfigEntry.find('features/dark_mode.yml')
+config.rollback_to(
+ commit_id: 'abc123',
+ author: current_user,
+ message: 'Rollback: Dark mode causing crashes'
+)
+```
+
+### Comparing Configurations
+
+```ruby
+# See what changed between versions
+config = ConfigEntry.find('features/dark_mode.yml')
+diff = config.diff_with('abc123')
+puts diff
+# -rollout_percentage: 50
+# +rollout_percentage: 100
+```
+
+## API Endpoints
+
+| Method | Path | Description |
+|--------|------|-------------|
+| GET | /configs | List all configs |
+| GET | /configs/:path | Get config value |
+| POST | /configs | Create config |
+| PATCH | /configs/:path | Update config |
+| DELETE | /configs/:path | Delete config |
+| GET | /configs/:path/history | Version history |
+| POST | /configs/:path/rollback | Rollback to version |
+
+## Security Considerations
+
+1. **Access Control** - Implement authentication/authorization for config changes
+2. **Sensitive Data** - Use Rails credentials for secrets, not this system
+3. **Validation** - Validate configs before saving to prevent invalid states
+4. **Audit Logging** - All changes are tracked in Git history
diff --git a/examples/config_management/app/controllers/configs_controller.rb b/examples/config_management/app/controllers/configs_controller.rb
new file mode 100644
index 0000000..6ccd845
--- /dev/null
+++ b/examples/config_management/app/controllers/configs_controller.rb
@@ -0,0 +1,168 @@
+# frozen_string_literal: true
+
+# Controller for managing versioned application configurations.
+# Provides CRUD operations with full version history and rollback support.
+#
+# NOTE: This controller expects to inherit from ApplicationController which should
+# have CSRF protection enabled (protect_from_forgery with: :exception).
+# See Rails security guide: https://guides.rubyonrails.org/security.html
+#
+class ConfigsController < ApplicationController
+ before_action :set_config, only: [:show, :edit, :update, :destroy, :history, :rollback, :diff]
+ before_action :refresh_store
+
+ # GET /configs
+ # List all configurations grouped by directory
+ def index
+ @configs = if params[:q].present?
+ ConfigEntry.search(params[:q])
+ else
+ ConfigEntry.all
+ end
+ @grouped_configs = @configs.group_by { |c| File.dirname(c.path) }
+ end
+
+ # GET /configs/environments
+ # List available environments
+ def environments
+ @environments = ConfigEntry.environments
+ end
+
+ # GET /configs/environment/:env
+ # Show configs for a specific environment
+ def environment
+ @environment = params[:env]
+ @configs = ConfigEntry.for_environment(@environment)
+ end
+
+ # GET /configs/:path
+ # Show a specific configuration
+ def show
+ respond_to do |format|
+ format.html
+ format.json { render json: @config.to_hash }
+ format.yaml { render plain: @config.value.to_yaml }
+ end
+ end
+
+ # GET /configs/new
+ # Form to create a new configuration
+ def new
+ @config = ConfigEntry.new(path: params[:path])
+ end
+
+ # GET /configs/:path/edit
+ # Form to edit a configuration
+ def edit
+ end
+
+ # POST /configs
+ # Create a new configuration
+ def create
+ @config = ConfigEntry.new(config_params)
+ @config.value = parse_value(params[:config][:value_text], @config.path)
+
+ if @config.save(author: current_user, message: params[:commit_message])
+ redirect_to config_path(@config.path), notice: 'Configuration was successfully created.'
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ # PATCH/PUT /configs/:path
+ # Update a configuration
+ def update
+ @config.value = parse_value(params[:config][:value_text], @config.path)
+
+ if @config.save(author: current_user, message: params[:commit_message])
+ redirect_to config_path(@config.path), notice: 'Configuration was successfully updated.'
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ # DELETE /configs/:path
+ # Delete a configuration
+ def destroy
+ @config.destroy(author: current_user, message: params[:commit_message])
+ redirect_to configs_path, notice: 'Configuration was successfully deleted.'
+ end
+
+ # GET /configs/:path/history
+ # Show version history
+ def history
+ @commits = @config.history(limit: 50)
+ end
+
+ # POST /configs/:path/rollback
+ # Rollback to a previous version
+ def rollback
+ commit_id = params[:commit_id]
+
+ if @config.rollback_to(commit_id: commit_id, author: current_user)
+ redirect_to config_path(@config.path), notice: "Successfully rolled back to version #{commit_id[0..7]}."
+ else
+ redirect_to history_config_path(@config.path), alert: 'Failed to rollback.'
+ end
+ end
+
+ # GET /configs/:path/diff
+ # Show diff between versions
+ def diff
+ @from_commit = params[:from]
+ @to_commit = params[:to] || store.head&.id
+
+ if @from_commit && @to_commit
+ @diff = @config.diff_with(@from_commit)
+ @from_version = @config.version_at(@from_commit)
+ @to_version = @config.version_at(@to_commit)
+ else
+ redirect_to history_config_path(@config.path), alert: 'Please select two versions to compare.'
+ end
+ end
+
+ private
+
+ def set_config
+ @config = ConfigEntry.find!(params[:path])
+ rescue ConfigEntry::RecordNotFound
+ redirect_to new_config_path(path: params[:path])
+ end
+
+ def refresh_store
+ store.refresh!
+ end
+
+ def config_params
+ params.require(:config).permit(:path)
+ end
+
+ def parse_value(text, path)
+ return {} if text.blank?
+
+ ext = File.extname(path).delete('.')
+ case ext
+ when 'json'
+ JSON.parse(text)
+ when 'yml', 'yaml'
+ YAML.safe_load(text, permitted_classes: [Date, Time])
+ else
+ text
+ end
+ rescue JSON::ParserError, Psych::SyntaxError => e
+ flash.now[:alert] = "Invalid #{ext.upcase}: #{e.message}"
+ nil
+ end
+
+ def store
+ Rails.application.config.config_store
+ end
+
+ def current_user
+ @current_user ||= OpenStruct.new(
+ name: session[:user_name] || 'Admin',
+ email: session[:user_email] || 'admin@example.com'
+ )
+ end
+ helper_method :current_user
+end
diff --git a/examples/config_management/app/models/config_entry.rb b/examples/config_management/app/models/config_entry.rb
new file mode 100644
index 0000000..3de25d4
--- /dev/null
+++ b/examples/config_management/app/models/config_entry.rb
@@ -0,0 +1,319 @@
+# frozen_string_literal: true
+
+require 'git_store'
+require 'json'
+
+# ConfigEntry model for managing versioned application configurations.
+# Supports YAML and JSON formats with full version history.
+#
+# @example Setting a configuration
+# ConfigEntry.set('features/beta.yml', { enabled: true }, author: user)
+#
+# @example Getting a configuration
+# config = ConfigEntry.get('features/beta.yml')
+# # => { 'enabled' => true }
+#
+class ConfigEntry
+ include ActiveModel::Model
+ include ActiveModel::Attributes
+
+ # Supported file formats
+ SUPPORTED_FORMATS = %w[yml yaml json].freeze
+
+ attribute :path, :string
+ attribute :value
+ attribute :format, :string
+ attribute :updated_at, :datetime
+ attribute :author_name, :string
+ attribute :author_email, :string
+
+ validates :path, presence: true
+ validates :value, presence: true
+ validate :validate_format
+
+ class << self
+ # Get a configuration value by path
+ #
+ # @param path [String] The config path (e.g., 'features/beta.yml')
+ # @return [Hash, Array, String, nil] The configuration value
+ def get(path)
+ store[path]
+ end
+
+ # Set a configuration value
+ #
+ # @param path [String] The config path
+ # @param value [Hash, Array, String] The configuration value
+ # @param author [User, nil] The author making the change
+ # @param message [String, nil] The commit message
+ # @return [Boolean] True if saved successfully
+ def set(path, value, author: nil, message: nil)
+ entry = new(path: path, value: value)
+ entry.save(author: author, message: message)
+ end
+
+ # Find a config entry by path
+ #
+ # @param path [String] The config path
+ # @return [ConfigEntry, nil] The entry or nil
+ def find(path)
+ value = store[path]
+ return nil unless value
+
+ new(
+ path: path,
+ value: value,
+ format: File.extname(path).delete('.'),
+ updated_at: Time.current
+ )
+ end
+
+ # Find a config entry, raising if not found
+ #
+ # @param path [String] The config path
+ # @return [ConfigEntry] The entry
+ # @raise [RecordNotFound] If not found
+ def find!(path)
+ find(path) or raise RecordNotFound, "Config '#{path}' not found"
+ end
+
+ # List all configuration files
+ #
+ # @return [Array] All configs
+ def all
+ entries = []
+ collect_entries(store.root, '', entries)
+ entries.sort_by(&:path)
+ end
+
+ # Get configs for a specific environment
+ #
+ # @param env [String] Environment name (e.g., 'production')
+ # @return [Array] Configs for that environment
+ def for_environment(env)
+ all.select { |entry| entry.path.start_with?("environments/#{env}/") }
+ end
+
+ # List available environments
+ #
+ # @return [Array] Environment names
+ def environments
+ all.map { |entry| entry.path.split('/')[1] if entry.path.start_with?('environments/') }
+ .compact
+ .uniq
+ .sort
+ end
+
+ # Search configs by path pattern
+ #
+ # @param pattern [String] Search pattern
+ # @return [Array] Matching configs
+ def search(pattern)
+ pattern_downcase = pattern.downcase
+ all.select { |entry| entry.path.downcase.include?(pattern_downcase) }
+ end
+
+ # Delete a configuration
+ #
+ # @param path [String] The config path
+ # @param author [User, nil] The author
+ # @param message [String, nil] The commit message
+ # @return [Boolean] True if deleted
+ def delete(path, author: nil, message: nil)
+ msg = message || "Deleted config: #{path}"
+ store.transaction(msg, git_author(author)) do
+ store.delete(path)
+ end
+ true
+ rescue StandardError
+ false
+ end
+
+ # Access the GitStore instance
+ def store
+ Rails.application.config.config_store
+ end
+
+ private
+
+ def collect_entries(tree, prefix, entries)
+ return unless tree.is_a?(GitStore::Tree)
+
+ tree.table.each do |name, entry|
+ full_path = prefix.empty? ? name : "#{prefix}/#{name}"
+
+ if entry.is_a?(GitStore::Tree)
+ collect_entries(entry, full_path, entries)
+ elsif SUPPORTED_FORMATS.include?(File.extname(name).delete('.'))
+ value = tree[name]
+ entries << new(
+ path: full_path,
+ value: value,
+ format: File.extname(name).delete('.')
+ )
+ end
+ end
+ end
+
+ def git_author(author)
+ name = author&.respond_to?(:name) ? author.name : 'System'
+ email = author&.respond_to?(:email) ? author.email : 'system@example.com'
+ GitStore::User.new(name, email, Time.now)
+ end
+ end
+
+ # Check if this is a new record
+ def new_record?
+ !self.class.find(path)
+ end
+
+ # Check if persisted
+ def persisted?
+ !new_record?
+ end
+
+ # Save the configuration
+ #
+ # @param author [User, nil] The author
+ # @param message [String, nil] The commit message
+ # @return [Boolean] True if saved
+ def save(author: nil, message: nil)
+ return false unless valid?
+
+ self.updated_at = Time.current
+ self.author_name = author&.respond_to?(:name) ? author.name : 'System'
+ self.author_email = author&.respond_to?(:email) ? author.email : 'system@example.com'
+
+ msg = message || (new_record? ? "Created config: #{path}" : "Updated config: #{path}")
+
+ store.transaction(msg, git_author(author)) do
+ store[path] = value
+ end
+
+ true
+ rescue StandardError => e
+ errors.add(:base, e.message)
+ false
+ end
+
+ # Delete this configuration
+ #
+ # @param author [User, nil] The author
+ # @param message [String, nil] The commit message
+ # @return [Boolean] True if deleted
+ def destroy(author: nil, message: nil)
+ self.class.delete(path, author: author, message: message)
+ end
+
+ # Get version history for this config
+ #
+ # @param limit [Integer] Maximum commits to return
+ # @return [Array] Commit history
+ def history(limit: 50)
+ commits = []
+ current = store.head
+
+ while current && commits.size < limit
+ if commit_affects_config?(current)
+ commits << current
+ end
+ parent_id = current.parent.first
+ current = parent_id ? store.get(parent_id) : nil
+ end
+
+ commits
+ end
+
+ # Get the config value at a specific commit
+ #
+ # @param commit_id [String] The commit SHA
+ # @return [ConfigEntry, nil] The config at that commit
+ def version_at(commit_id)
+ commit = store.get(commit_id)
+ return nil unless commit
+
+ value = commit.tree[path]
+ return nil unless value
+
+ self.class.new(
+ path: path,
+ value: value,
+ format: format
+ )
+ end
+
+ # Rollback to a previous version
+ #
+ # @param commit_id [String] The commit SHA
+ # @param author [User, nil] The author
+ # @param message [String, nil] The commit message
+ # @return [Boolean] True if successful
+ def rollback_to(commit_id:, author: nil, message: nil)
+ previous = version_at(commit_id)
+ return false unless previous
+
+ self.value = previous.value
+ msg = message || "Rolled back config #{path} to version #{commit_id[0..7]}"
+ save(author: author, message: msg)
+ end
+
+ # Get diff with another version
+ #
+ # @param other_commit_id [String] The other commit SHA
+ # @return [String] Diff output
+ def diff_with(other_commit_id)
+ store.head.diff(other_commit_id, path)
+ end
+
+ # Convert to hash for API responses
+ #
+ # @return [Hash] The config data
+ def to_hash
+ {
+ 'path' => path,
+ 'value' => value,
+ 'format' => format,
+ 'updated_at' => updated_at&.iso8601
+ }
+ end
+
+ # For routing
+ def to_param
+ path
+ end
+
+ private
+
+ def store
+ self.class.store
+ end
+
+ def git_author(author)
+ name = author&.respond_to?(:name) ? author.name : (author_name || 'System')
+ email = author&.respond_to?(:email) ? author.email : (author_email || 'system@example.com')
+ GitStore::User.new(name, email, Time.now)
+ end
+
+ def validate_format
+ return if path.blank?
+
+ ext = File.extname(path).delete('.')
+ unless SUPPORTED_FORMATS.include?(ext)
+ errors.add(:path, "must end with #{SUPPORTED_FORMATS.join(', ')}")
+ end
+ end
+
+ def commit_affects_config?(commit)
+ return false unless commit.parent.any?
+
+ parent = store.get(commit.parent.first)
+ return true unless parent
+
+ current_blob = commit.tree[path]
+ parent_blob = parent.tree[path]
+
+ current_blob != parent_blob
+ end
+
+ class RecordNotFound < StandardError; end
+end
diff --git a/examples/config_management/app/views/configs/index.html.erb b/examples/config_management/app/views/configs/index.html.erb
new file mode 100644
index 0000000..e1e783e
--- /dev/null
+++ b/examples/config_management/app/views/configs/index.html.erb
@@ -0,0 +1,53 @@
+
+
+
Configuration Management
+
+ <%= link_to 'Environments', environments_configs_path, class: 'bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded' %>
+ <%= link_to 'New Config', new_config_path, class: 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded' %>
+
+
+
+
+ <%= form_with url: configs_path, method: :get, class: 'flex gap-2' do %>
+ <%= text_field_tag :q, params[:q], placeholder: 'Search configurations...', class: 'flex-1 px-4 py-2 border rounded' %>
+ <%= submit_tag 'Search', class: 'bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded cursor-pointer' %>
+ <% end %>
+
+
+ <% if @configs.empty? %>
+
+
No configurations yet. <%= link_to 'Create the first one', new_config_path, class: 'text-blue-500 hover:underline' %>
+
+ <% else %>
+
+ <% @grouped_configs.each do |directory, configs| %>
+
+
+
+ 📁 <%= directory %>
+
+
+
+ <% configs.each do |config| %>
+
+
+
+ <%= link_to File.basename(config.path), config_path(config.path), class: 'text-blue-600 hover:text-blue-800' %>
+
+
+ <%= config.format.upcase %>
+
+
+
+ <%= link_to 'View', config_path(config.path), class: 'text-blue-600 hover:text-blue-800' %>
+ <%= link_to 'Edit', edit_config_path(config.path), class: 'text-green-600 hover:text-green-800' %>
+ <%= link_to 'History', history_config_path(config.path), class: 'text-gray-600 hover:text-gray-800' %>
+
+
+ <% end %>
+
+
+ <% end %>
+
+ <% end %>
+
diff --git a/examples/config_management/app/views/configs/show.html.erb b/examples/config_management/app/views/configs/show.html.erb
new file mode 100644
index 0000000..052239b
--- /dev/null
+++ b/examples/config_management/app/views/configs/show.html.erb
@@ -0,0 +1,33 @@
+
+
+ <%= link_to 'Configs', configs_path %> / <%= h @config.path %>
+
+
+
+
+
+
<%= h @config.path %>
+ Format: <%= h @config.format.upcase %>
+
+
+ <%= link_to 'Edit', edit_config_path(@config.path), class: 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded' %>
+ <%= link_to 'History', history_config_path(@config.path), class: 'bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded' %>
+
+
+
+
+
Current Value
+
<%= h @config.value.to_yaml %>
+
+
+
+
+ <%= link_to 'Download YAML', config_path(@config.path, format: :yaml), class: 'text-blue-600 hover:text-blue-800' %>
+ <%= link_to 'Download JSON', config_path(@config.path, format: :json), class: 'text-blue-600 hover:text-blue-800' %>
+ <%= button_to 'Delete', config_path(@config.path), method: :delete,
+ class: 'text-red-600 hover:text-red-800',
+ data: { turbo_confirm: 'Are you sure you want to delete this configuration?' } %>
+
+
+
+
diff --git a/examples/config_management/config/initializers/git_store.rb b/examples/config_management/config/initializers/git_store.rb
new file mode 100644
index 0000000..02d6529
--- /dev/null
+++ b/examples/config_management/config/initializers/git_store.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+# GitStore configuration for Rails 8 - Configuration Management
+# Stores application configurations in a versioned Git repository
+
+Rails.application.config.to_prepare do
+ # Path to the configuration repository
+ config_repo_path = Rails.root.join('config_repo')
+
+ # Ensure the repository exists
+ unless File.exist?(config_repo_path.join('.git'))
+ FileUtils.mkdir_p(config_repo_path)
+ Dir.chdir(config_repo_path) do
+ # Use array form to avoid shell injection
+ system('git', 'init')
+ system('git', 'config', 'user.name', 'Config System')
+ system('git', 'config', 'user.email', 'config@example.com')
+ end
+ end
+
+ # Initialize the GitStore instance for configs
+ Rails.application.config.config_store = GitStore.new(config_repo_path.to_s)
+end
+
+# Helper module to access the config store
+module ConfigStoreHelper
+ def config_store
+ Rails.application.config.config_store
+ end
+end
diff --git a/examples/config_management/config/routes.rb b/examples/config_management/config/routes.rb
new file mode 100644
index 0000000..cd09779
--- /dev/null
+++ b/examples/config_management/config/routes.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+Rails.application.routes.draw do
+ # Configuration management routes
+ resources :configs, param: :path, path: 'configs', constraints: { path: /.*/ } do
+ member do
+ get :history
+ post :rollback
+ get :diff
+ end
+ collection do
+ get :environments
+ get 'environment/:env', action: :environment, as: :environment
+ end
+ end
+
+ # API namespace for programmatic access
+ namespace :api do
+ namespace :v1 do
+ resources :configs, param: :path, constraints: { path: /.*/ }, only: [:index, :show, :create, :update, :destroy]
+ end
+ end
+
+ root 'configs#index'
+end
diff --git a/examples/wiki_cms/README.md b/examples/wiki_cms/README.md
new file mode 100644
index 0000000..77114da
--- /dev/null
+++ b/examples/wiki_cms/README.md
@@ -0,0 +1,89 @@
+# GitStore Wiki/CMS Example
+
+A complete Rails 8 wiki application powered by GitStore for versioned content management.
+
+## Features
+
+- **Full version history** - Every edit creates a Git commit
+- **Rollback support** - Revert to any previous version instantly
+- **Diff viewing** - See exactly what changed between versions
+- **Branch support** - Draft content in branches, merge when ready
+- **Git-native storage** - Content can be edited via Git or the web UI
+
+## Quick Start
+
+```bash
+# Create a new Rails 8 application
+rails new wiki_app --database=sqlite3 --css=tailwind
+
+# Add git_store to Gemfile
+echo "gem 'git_store', path: '../../../'" >> Gemfile
+
+# Install dependencies
+bundle install
+
+# Copy the example files to your Rails app
+cp -r app/* wiki_app/app/
+cp -r config/routes.rb wiki_app/config/
+cp -r config/initializers/* wiki_app/config/initializers/
+
+# Initialize the content repository
+mkdir -p wiki_app/content_repo
+cd wiki_app/content_repo && git init
+
+# Run the application
+cd wiki_app && rails server
+```
+
+## Directory Structure
+
+```
+wiki_cms/
+├── app/
+│ ├── controllers/
+│ │ └── pages_controller.rb # CRUD for wiki pages
+│ ├── models/
+│ │ └── page.rb # Page model using GitStore
+│ ├── views/
+│ │ └── pages/ # ERB templates
+│ └── helpers/
+│ └── pages_helper.rb # View helpers
+├── config/
+│ ├── routes.rb # Route configuration
+│ └── initializers/
+│ └── git_store.rb # GitStore configuration
+└── README.md
+```
+
+## Usage
+
+### Creating a page
+
+```ruby
+# Via Rails console
+page = Page.new(
+ slug: 'getting-started',
+ title: 'Getting Started Guide',
+ content: '# Welcome\n\nThis is your first wiki page.',
+ author: 'john@example.com'
+)
+page.save(message: 'Created getting started guide')
+```
+
+### Viewing history
+
+```ruby
+# Get all versions of a page
+page = Page.find('getting-started')
+page.history.each do |commit|
+ puts "#{commit.author.name} - #{commit.message} - #{commit.author.time}"
+end
+```
+
+### Rolling back
+
+```ruby
+# Revert to a previous version
+page = Page.find('getting-started')
+page.rollback_to(commit_id: 'abc123', message: 'Reverting bad edit')
+```
diff --git a/examples/wiki_cms/app/controllers/pages_controller.rb b/examples/wiki_cms/app/controllers/pages_controller.rb
new file mode 100644
index 0000000..192c77d
--- /dev/null
+++ b/examples/wiki_cms/app/controllers/pages_controller.rb
@@ -0,0 +1,176 @@
+# frozen_string_literal: true
+
+# Controller for wiki pages with full CRUD operations and version history.
+# Provides a complete wiki experience with version control features.
+#
+# NOTE: This controller expects to inherit from ApplicationController which should
+# have CSRF protection enabled (protect_from_forgery with: :exception).
+# See Rails security guide: https://guides.rubyonrails.org/security.html
+#
+# @example Routes
+# GET /pages # List all pages
+# GET /pages/new # New page form
+# POST /pages # Create page
+# GET /pages/:slug # Show page
+# GET /pages/:slug/edit # Edit form
+# PATCH /pages/:slug # Update page
+# DELETE /pages/:slug # Delete page
+# GET /pages/:slug/history # Version history
+# GET /pages/:slug/diff # Show diff
+# POST /pages/:slug/rollback # Rollback to version
+#
+class PagesController < ApplicationController
+ before_action :set_page, only: [:show, :edit, :update, :destroy, :history, :diff, :rollback, :raw]
+ before_action :refresh_store
+
+ # GET /pages
+ # List all wiki pages
+ def index
+ @pages = Page.all
+ end
+
+ # GET /pages/recent
+ # Show recently updated pages
+ def recent
+ @pages = Page.recent(limit: 20)
+ render :index
+ end
+
+ # GET /pages/search?q=query
+ # Search pages
+ def search
+ @query = params[:q].to_s.strip
+ @pages = @query.present? ? Page.search(@query) : Page.all
+ render :index
+ end
+
+ # GET /pages/:slug
+ # Show a specific page
+ def show
+ respond_to do |format|
+ format.html
+ format.json { render json: @page.to_hash }
+ end
+ end
+
+ # GET /pages/:slug/raw
+ # Show raw markdown content
+ def raw
+ render plain: @page.content, content_type: 'text/plain'
+ end
+
+ # GET /pages/new
+ # New page form
+ def new
+ @page = Page.new(slug: params[:slug])
+ end
+
+ # GET /pages/:slug/edit
+ # Edit page form
+ def edit
+ end
+
+ # POST /pages
+ # Create a new page
+ def create
+ @page = Page.new(page_params)
+
+ if @page.save(author: current_user, message: params[:commit_message])
+ redirect_to page_path(@page), notice: 'Page was successfully created.'
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ # PATCH/PUT /pages/:slug
+ # Update a page
+ def update
+ @page.assign_attributes(page_params)
+
+ if @page.save(author: current_user, message: params[:commit_message])
+ redirect_to page_path(@page), notice: 'Page was successfully updated.'
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ # DELETE /pages/:slug
+ # Delete a page
+ def destroy
+ @page.destroy(author: current_user, message: params[:commit_message])
+ redirect_to pages_path, notice: 'Page was successfully deleted.'
+ end
+
+ # GET /pages/:slug/history
+ # Show version history
+ def history
+ @commits = @page.history(limit: 50)
+ end
+
+ # GET /pages/:slug/diff?from=abc123&to=def456
+ # Show diff between versions
+ def diff
+ @from_commit = params[:from]
+ @to_commit = params[:to] || store.head&.id
+
+ if @from_commit && @to_commit
+ @diff = @page.diff_with(@from_commit)
+ @from_version = @page.version_at(@from_commit)
+ @to_version = @page.version_at(@to_commit)
+ else
+ redirect_to history_page_path(@page), alert: 'Please select two versions to compare.'
+ end
+ end
+
+ # POST /pages/:slug/rollback
+ # Rollback to a previous version
+ def rollback
+ commit_id = params[:commit_id]
+
+ if @page.rollback_to(commit_id: commit_id, author: current_user)
+ redirect_to page_path(@page), notice: "Successfully rolled back to version #{commit_id[0..7]}."
+ else
+ redirect_to history_page_path(@page), alert: 'Failed to rollback. The version may not exist.'
+ end
+ end
+
+ private
+
+ # Find the page by slug
+ def set_page
+ @page = Page.find!(params[:slug])
+ rescue Page::RecordNotFound
+ if action_name == 'show'
+ redirect_to new_page_path(slug: params[:slug])
+ else
+ redirect_to pages_path, alert: 'Page not found.'
+ end
+ end
+
+ # Refresh the store to pick up external changes
+ def refresh_store
+ store.refresh!
+ end
+
+ # Strong parameters for page
+ def page_params
+ params.require(:page).permit(:slug, :title, :content, tags: [])
+ end
+
+ # Access the GitStore
+ def store
+ Rails.application.config.content_store
+ end
+
+ # Helper for current user (implement based on your auth system)
+ def current_user
+ # Return current authenticated user
+ # Example with Devise: current_user
+ # Example with basic auth: request.env['REMOTE_USER']
+ @current_user ||= OpenStruct.new(
+ name: session[:user_name] || 'Anonymous',
+ email: session[:user_email] || 'anonymous@example.com'
+ )
+ end
+ helper_method :current_user
+end
diff --git a/examples/wiki_cms/app/helpers/pages_helper.rb b/examples/wiki_cms/app/helpers/pages_helper.rb
new file mode 100644
index 0000000..49b201e
--- /dev/null
+++ b/examples/wiki_cms/app/helpers/pages_helper.rb
@@ -0,0 +1,131 @@
+# frozen_string_literal: true
+
+# Helper methods for rendering wiki pages
+module PagesHelper
+ # Render markdown content to HTML
+ #
+ # NOTE: This is a simplified markdown renderer for demonstration purposes.
+ # In production, use a proper markdown library like 'redcarpet' or 'kramdown'
+ # which handles security concerns properly.
+ #
+ # @param content [String] Markdown content
+ # @return [String] HTML content
+ def render_markdown(content)
+ return '' if content.blank?
+
+ # First, HTML-escape all content to prevent XSS
+ html = ERB::Util.html_escape(content)
+
+ # Headers - only allow text content (already escaped)
+ html.gsub!(/^### (.+)$/, '\1 ')
+ html.gsub!(/^## (.+)$/, '\1 ')
+ html.gsub!(/^# (.+)$/, '\1 ')
+
+ # Bold and italic
+ html.gsub!(/\*\*(.+?)\*\*/, '\1 ')
+ html.gsub!(/\*(.+?)\*/, '\1 ')
+
+ # Code blocks (content already escaped)
+ html.gsub!(/```(\w*)\n(.*?)```/m) do
+ lang = ::Regexp.last_match(1)
+ code = ::Regexp.last_match(2)
+ "#{code} "
+ end
+
+ # Inline code (content already escaped)
+ html.gsub!(/`([^`]+)`/, '\1')
+
+ # Links - URL is already escaped, validate it's not javascript:
+ html.gsub!(/\[([^\]]+)\]\(([^)]+)\)/) do
+ text = ::Regexp.last_match(1)
+ url = ::Regexp.last_match(2)
+ # Reject potentially dangerous URLs (javascript:, data:, etc.)
+ safe_url = url.match?(/\A(https?:|\/|#)/) ? url : '#'
+ "#{text} "
+ end
+
+ # Wiki-style links [[Page Name]] - generate safe internal links
+ html.gsub!(/\[\[([^\]]+)\]\]/) do
+ page_name = ::Regexp.last_match(1)
+ # Sanitize slug to only allow alphanumeric, dashes, underscores
+ slug = page_name.downcase.gsub(/[^a-z0-9\-_]/, '-').gsub(/-+/, '-')
+ "#{page_name} "
+ end
+
+ # Paragraphs
+ html.gsub!(/\n\n+/, '
')
+ html = "
#{html}
"
+
+ html.html_safe
+ end
+
+ # Format a timestamp in a human-readable way
+ #
+ # @param time [Time] The timestamp
+ # @return [String] Formatted time
+ def format_time(time)
+ return 'Unknown' unless time
+
+ if time.to_date == Date.today
+ "Today at #{time.strftime('%H:%M')}"
+ elsif time.to_date == Date.yesterday
+ "Yesterday at #{time.strftime('%H:%M')}"
+ else
+ time.strftime('%B %d, %Y at %H:%M')
+ end
+ end
+
+ # Truncate commit SHA for display
+ #
+ # @param sha [String] Full SHA
+ # @return [String] Short SHA
+ def short_sha(sha)
+ sha&.first(7)
+ end
+
+ # Generate breadcrumb for a page
+ #
+ # @param page [Page] The page
+ # @return [String] HTML breadcrumb
+ def page_breadcrumb(page)
+ links = [link_to('Wiki', pages_path)]
+ links << link_to(page.title, page_path(page)) if page.persisted?
+ safe_join(links, ' / ')
+ end
+
+ # Highlight search terms in text
+ #
+ # @param text [String] The text to highlight
+ # @param query [String] The search query
+ # @return [String] Text with highlighted terms
+ def highlight_search(text, query)
+ return ERB::Util.html_escape(text) if query.blank?
+
+ escaped_text = ERB::Util.html_escape(text)
+ escaped_query = ERB::Util.html_escape(query)
+ escaped_text.gsub(/(#{Regexp.escape(escaped_query)})/i, '\1 ').html_safe
+ end
+
+ # Generate diff HTML from diff output
+ #
+ # @param diff [String] Diff output
+ # @return [String] HTML diff
+ def format_diff(diff)
+ return 'No changes
'.html_safe if diff.blank?
+
+ lines = diff.to_s.lines.map do |line|
+ case line[0]
+ when '+'
+ "#{ERB::Util.html_escape(line)}
"
+ when '-'
+ "#{ERB::Util.html_escape(line)}
"
+ when '@'
+ "#{ERB::Util.html_escape(line)}
"
+ else
+ "#{ERB::Util.html_escape(line)}
"
+ end
+ end
+
+ "#{lines.join} ".html_safe
+ end
+end
diff --git a/examples/wiki_cms/app/models/page.rb b/examples/wiki_cms/app/models/page.rb
new file mode 100644
index 0000000..d23fb16
--- /dev/null
+++ b/examples/wiki_cms/app/models/page.rb
@@ -0,0 +1,278 @@
+# frozen_string_literal: true
+
+require 'git_store'
+
+# Page model that uses GitStore for versioned content storage.
+# Each page is stored as a YAML file in the Git repository, providing
+# full version history, branching, and rollback capabilities.
+#
+# @example Creating a new page
+# page = Page.new(slug: 'home', title: 'Home Page', content: '# Welcome')
+# page.save(author: current_user, message: 'Created home page')
+#
+# @example Finding and updating a page
+# page = Page.find('home')
+# page.content = '# Updated Welcome'
+# page.save(author: current_user, message: 'Updated home page content')
+#
+class Page
+ include ActiveModel::Model
+ include ActiveModel::Attributes
+ include ActiveModel::Validations
+
+ # Attributes stored in the YAML file
+ attribute :slug, :string
+ attribute :title, :string
+ attribute :content, :string
+ attribute :created_at, :datetime
+ attribute :updated_at, :datetime
+ attribute :author_name, :string
+ attribute :author_email, :string
+ attribute :tags, default: []
+
+ # Validations
+ validates :slug, presence: true, format: { with: /\A[a-z0-9\-_]+\z/i,
+ message: 'only allows alphanumeric characters, dashes, and underscores' }
+ validates :title, presence: true
+ validates :content, presence: true
+
+ class << self
+ # Find a page by its slug
+ #
+ # @param slug [String] The page slug
+ # @return [Page, nil] The page or nil if not found
+ def find(slug)
+ data = store["pages/#{slug}.yml"]
+ return nil unless data
+
+ new(data.merge(slug: slug))
+ end
+
+ # Find a page by slug, raising an error if not found
+ #
+ # @param slug [String] The page slug
+ # @return [Page] The page
+ # @raise [RecordNotFound] If the page doesn't exist
+ def find!(slug)
+ find(slug) or raise RecordNotFound, "Page '#{slug}' not found"
+ end
+
+ # Get all pages in the wiki
+ #
+ # @return [Array] All pages sorted by title
+ def all
+ pages = []
+ store['pages']&.each do |path, data|
+ next unless path.end_with?('.yml')
+ slug = path.sub('.yml', '')
+ pages << new(data.merge(slug: slug))
+ end
+ pages.sort_by(&:title)
+ end
+
+ # Get recently updated pages
+ #
+ # @param limit [Integer] Maximum number of pages to return
+ # @return [Array] Recently updated pages
+ def recent(limit: 10)
+ all.sort_by { |p| p.updated_at || Time.at(0) }.reverse.first(limit)
+ end
+
+ # Search pages by title or content
+ #
+ # @param query [String] Search query
+ # @return [Array] Matching pages
+ def search(query)
+ query_downcase = query.downcase
+ all.select do |page|
+ page.title.downcase.include?(query_downcase) ||
+ page.content.downcase.include?(query_downcase)
+ end
+ end
+
+ # Check if a page exists
+ #
+ # @param slug [String] The page slug
+ # @return [Boolean] True if the page exists
+ def exists?(slug)
+ store["pages/#{slug}.yml"].present?
+ end
+
+ # Access the GitStore instance
+ #
+ # @return [GitStore] The content store
+ def store
+ Rails.application.config.content_store
+ end
+ end
+
+ # Check if this is a new record
+ #
+ # @return [Boolean] True if not yet saved
+ def new_record?
+ !self.class.exists?(slug)
+ end
+
+ # Check if this record has been persisted
+ #
+ # @return [Boolean] True if saved
+ def persisted?
+ !new_record?
+ end
+
+ # Save the page to the GitStore
+ #
+ # @param author [User, nil] The author making the change
+ # @param message [String] The commit message
+ # @return [Boolean] True if saved successfully
+ def save(author: nil, message: nil)
+ return false unless valid?
+
+ self.updated_at = Time.current
+ self.created_at ||= Time.current
+
+ if author
+ self.author_name = author.respond_to?(:name) ? author.name : author.to_s
+ self.author_email = author.respond_to?(:email) ? author.email : "#{author}@example.com"
+ end
+
+ store.transaction(commit_message(message), git_author) do
+ store["pages/#{slug}.yml"] = to_hash
+ end
+
+ true
+ rescue StandardError => e
+ errors.add(:base, e.message)
+ false
+ end
+
+ # Delete the page from the GitStore
+ #
+ # @param author [User, nil] The author making the deletion
+ # @param message [String] The commit message
+ # @return [Boolean] True if deleted successfully
+ def destroy(author: nil, message: nil)
+ return false unless persisted?
+
+ msg = message || "Deleted page: #{title}"
+ store.transaction(msg, git_author(author)) do
+ store.delete("pages/#{slug}.yml")
+ end
+
+ true
+ rescue StandardError => e
+ errors.add(:base, e.message)
+ false
+ end
+
+ # Get the version history for this page
+ #
+ # @param limit [Integer] Maximum number of commits to return
+ # @return [Array] The commit history
+ def history(limit: 50)
+ commits = []
+ current = store.head
+
+ while current && commits.size < limit
+ # Check if this commit modified our page
+ if commit_affects_page?(current)
+ commits << current
+ end
+ parent_id = current.parent.first if current.parent.any?
+ current = parent_id ? store.get(parent_id) : nil
+ end
+
+ commits
+ end
+
+ # Get the page content at a specific commit
+ #
+ # @param commit_id [String] The commit SHA
+ # @return [Page, nil] The page at that commit
+ def version_at(commit_id)
+ commit = store.get(commit_id)
+ return nil unless commit
+
+ data = commit.tree["pages/#{slug}.yml"]
+ return nil unless data
+
+ self.class.new(data.merge(slug: slug))
+ end
+
+ # Rollback to a previous version
+ #
+ # @param commit_id [String] The commit SHA to rollback to
+ # @param author [User, nil] The author making the rollback
+ # @param message [String] The commit message
+ # @return [Boolean] True if rollback was successful
+ def rollback_to(commit_id:, author: nil, message: nil)
+ previous_version = version_at(commit_id)
+ return false unless previous_version
+
+ self.title = previous_version.title
+ self.content = previous_version.content
+ self.tags = previous_version.tags
+
+ msg = message || "Rolled back to version #{commit_id[0..7]}"
+ save(author: author, message: msg)
+ end
+
+ # Get the diff between this version and another
+ #
+ # @param other_commit_id [String] The other commit SHA
+ # @return [String] The diff output
+ def diff_with(other_commit_id)
+ store.head.diff(other_commit_id, "pages/#{slug}.yml")
+ end
+
+ # Convert to a hash for YAML serialization
+ #
+ # @return [Hash] The page data
+ def to_hash
+ {
+ 'title' => title,
+ 'content' => content,
+ 'created_at' => created_at&.iso8601,
+ 'updated_at' => updated_at&.iso8601,
+ 'author_name' => author_name,
+ 'author_email' => author_email,
+ 'tags' => tags
+ }.compact
+ end
+
+ # Used for routing
+ def to_param
+ slug
+ end
+
+ private
+
+ def store
+ self.class.store
+ end
+
+ def git_author(author = nil)
+ name = author&.respond_to?(:name) ? author.name : (author_name || 'System')
+ email = author&.respond_to?(:email) ? author.email : (author_email || 'system@example.com')
+ GitStore::User.new(name, email, Time.now)
+ end
+
+ def commit_message(message)
+ message || (new_record? ? "Created page: #{title}" : "Updated page: #{title}")
+ end
+
+ def commit_affects_page?(commit)
+ return false unless commit.parent.any?
+
+ parent = store.get(commit.parent.first)
+ return true unless parent
+
+ current_blob = commit.tree["pages/#{slug}.yml"]
+ parent_blob = parent.tree["pages/#{slug}.yml"]
+
+ current_blob != parent_blob
+ end
+
+ # Custom exception for record not found
+ class RecordNotFound < StandardError; end
+end
diff --git a/examples/wiki_cms/app/views/pages/_form.html.erb b/examples/wiki_cms/app/views/pages/_form.html.erb
new file mode 100644
index 0000000..ee05b6b
--- /dev/null
+++ b/examples/wiki_cms/app/views/pages/_form.html.erb
@@ -0,0 +1,52 @@
+<%= form_with model: page, url: page.persisted? ? page_path(page) : pages_path, class: 'space-y-4' do |form| %>
+ <% if page.errors.any? %>
+
+
<%= pluralize(page.errors.count, 'error') %> prevented this page from being saved:
+
+ <% page.errors.full_messages.each do |message| %>
+ <%= message %>
+ <% end %>
+
+
+ <% end %>
+
+
+ <%= form.label :slug, class: 'block text-sm font-medium text-gray-700' %>
+ <%= form.text_field :slug,
+ class: 'mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500',
+ placeholder: 'page-slug',
+ disabled: page.persisted? %>
+
URL-friendly identifier (letters, numbers, dashes)
+
+
+
+ <%= form.label :title, class: 'block text-sm font-medium text-gray-700' %>
+ <%= form.text_field :title,
+ class: 'mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500',
+ placeholder: 'Page Title' %>
+
+
+
+ <%= form.label :content, class: 'block text-sm font-medium text-gray-700' %>
+ <%= form.text_area :content,
+ rows: 15,
+ class: 'mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 font-mono',
+ placeholder: 'Write your content in Markdown...' %>
+
Supports Markdown formatting and [[Wiki Links]]
+
+
+
+ <%= label_tag :commit_message, 'Commit Message', class: 'block text-sm font-medium text-gray-700' %>
+ <%= text_field_tag :commit_message, nil,
+ class: 'mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500',
+ placeholder: page.persisted? ? 'Describe your changes...' : 'Initial page creation' %>
+
Describe what you changed (for version history)
+
+
+
+ <%= form.submit page.persisted? ? 'Update Page' : 'Create Page',
+ class: 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded cursor-pointer' %>
+ <%= link_to 'Cancel', page.persisted? ? page_path(page) : pages_path,
+ class: 'bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded' %>
+
+<% end %>
diff --git a/examples/wiki_cms/app/views/pages/diff.html.erb b/examples/wiki_cms/app/views/pages/diff.html.erb
new file mode 100644
index 0000000..c2a96c8
--- /dev/null
+++ b/examples/wiki_cms/app/views/pages/diff.html.erb
@@ -0,0 +1,73 @@
+
+
+ <%= link_to 'Wiki', pages_path %> /
+ <%= link_to @page.title, page_path(@page) %> /
+ <%= link_to 'History', history_page_path(@page) %> /
+ Diff
+
+
+
Changes: <%= @page.title %>
+
+
+
+
+ From: <%= short_sha(@from_commit) %>
+
+ <% if @from_version %>
+
+ <%= format_time(@from_version.updated_at) %>
+ by <%= @from_version.author_name %>
+
+ <% end %>
+
+
+
+ To: <%= short_sha(@to_commit) %>
+
+ <% if @to_version %>
+
+ <%= format_time(@to_version.updated_at) %>
+ by <%= @to_version.author_name %>
+
+ <% end %>
+
+
+
+
+
Diff
+ <%= format_diff(@diff) %>
+
+
+
+ <%= link_to 'Back to History', history_page_path(@page), class: 'text-blue-600 hover:text-blue-900' %>
+ <%= button_to "Rollback to #{short_sha(@from_commit)}",
+ rollback_page_path(@page, commit_id: @from_commit),
+ method: :post,
+ class: 'bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded',
+ data: { turbo_confirm: 'Are you sure you want to rollback to this version?' } %>
+
+
+
+
diff --git a/examples/wiki_cms/app/views/pages/edit.html.erb b/examples/wiki_cms/app/views/pages/edit.html.erb
new file mode 100644
index 0000000..dfc9285
--- /dev/null
+++ b/examples/wiki_cms/app/views/pages/edit.html.erb
@@ -0,0 +1,5 @@
+
+
Edit: <%= @page.title %>
+
+ <%= render 'form', page: @page %>
+
diff --git a/examples/wiki_cms/app/views/pages/history.html.erb b/examples/wiki_cms/app/views/pages/history.html.erb
new file mode 100644
index 0000000..099660d
--- /dev/null
+++ b/examples/wiki_cms/app/views/pages/history.html.erb
@@ -0,0 +1,67 @@
+
+
+ <%= link_to 'Wiki', pages_path %> /
+ <%= link_to @page.title, page_path(@page) %> /
+ History
+
+
+
History: <%= @page.title %>
+
+
+
+
+
+ Commit
+ Message
+ Author
+ Date
+ Actions
+
+
+
+ <% @commits.each_with_index do |commit, index| %>
+
+
+
+ <%= short_sha(commit.id) %>
+
+ <% if index == 0 %>
+
+ Current
+
+ <% end %>
+
+
+ <%= truncate(commit.message, length: 60) %>
+
+
+ <%= commit.author&.name %>
+ <%= commit.author&.email %>
+
+
+ <%= format_time(commit.author&.time) %>
+
+
+
+ <% if index > 0 %>
+ <%= link_to 'View', page_path(@page, version: commit.id),
+ class: 'text-blue-600 hover:text-blue-900' %>
+ <%= link_to 'Diff', diff_page_path(@page, from: commit.id, to: @commits[index - 1].id),
+ class: 'text-blue-600 hover:text-blue-900' %>
+ <%= button_to 'Rollback', rollback_page_path(@page, commit_id: commit.id),
+ method: :post,
+ class: 'text-red-600 hover:text-red-900',
+ data: { turbo_confirm: "Are you sure you want to rollback to version #{short_sha(commit.id)}?" } %>
+ <% end %>
+
+
+
+ <% end %>
+
+
+
+
+
+ <%= link_to 'Back to Page', page_path(@page), class: 'text-blue-600 hover:text-blue-900' %>
+
+
diff --git a/examples/wiki_cms/app/views/pages/index.html.erb b/examples/wiki_cms/app/views/pages/index.html.erb
new file mode 100644
index 0000000..8b04b88
--- /dev/null
+++ b/examples/wiki_cms/app/views/pages/index.html.erb
@@ -0,0 +1,50 @@
+
+
+
Wiki Pages
+ <%= link_to 'New Page', new_page_path, class: 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded' %>
+
+
+
+ <%= form_with url: search_pages_path, method: :get, class: 'flex gap-2' do %>
+ <%= text_field_tag :q, params[:q], placeholder: 'Search pages...', class: 'flex-1 px-4 py-2 border rounded' %>
+ <%= submit_tag 'Search', class: 'bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded cursor-pointer' %>
+ <% end %>
+
+
+ <% if @query.present? %>
+
+ Found <%= pluralize(@pages.size, 'result') %> for "<%= @query %>"
+
+ <% end %>
+
+
+ <% if @pages.empty? %>
+
+
No pages yet. <%= link_to 'Create the first page', new_page_path, class: 'text-blue-500 hover:underline' %>
+
+ <% else %>
+ <% @pages.each do |page| %>
+
+
+ <%= link_to page.title, page_path(page), class: 'text-blue-600 hover:text-blue-800' %>
+
+
+ <% if @query.present? %>
+ <%= highlight_search(truncate(page.content, length: 200), @query) %>
+ <% else %>
+ <%= truncate(page.content, length: 200) %>
+ <% end %>
+
+
+ <% if page.updated_at %>
+ Updated <%= format_time(page.updated_at) %>
+ <% end %>
+ <% if page.author_name %>
+ by <%= page.author_name %>
+ <% end %>
+
+
+ <% end %>
+ <% end %>
+
+
diff --git a/examples/wiki_cms/app/views/pages/new.html.erb b/examples/wiki_cms/app/views/pages/new.html.erb
new file mode 100644
index 0000000..f5b27ee
--- /dev/null
+++ b/examples/wiki_cms/app/views/pages/new.html.erb
@@ -0,0 +1,5 @@
+
+
New Page
+
+ <%= render 'form', page: @page %>
+
diff --git a/examples/wiki_cms/app/views/pages/show.html.erb b/examples/wiki_cms/app/views/pages/show.html.erb
new file mode 100644
index 0000000..5c7e939
--- /dev/null
+++ b/examples/wiki_cms/app/views/pages/show.html.erb
@@ -0,0 +1,41 @@
+
+
+ <%= page_breadcrumb(@page) %>
+
+
+
+
+ <%= h @page.title %>
+
+ <% if @page.updated_at %>
+ Last updated: <%= format_time(@page.updated_at) %>
+ <% end %>
+ <% if @page.author_name %>
+ by <%= h @page.author_name %>
+ <% end %>
+
+ <% if @page.tags.present? %>
+
+ <% @page.tags.each do |tag| %>
+
+ <%= h tag %>
+
+ <% end %>
+
+ <% end %>
+
+
+
+ <%= render_markdown(@page.content) %>
+
+
+
+
+ <%= link_to 'Edit', edit_page_path(@page), class: 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded' %>
+ <%= link_to 'History', history_page_path(@page), class: 'bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded' %>
+ <%= link_to 'Raw', raw_page_path(@page), class: 'bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded' %>
+ <%= button_to 'Delete', page_path(@page), method: :delete,
+ class: 'bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded',
+ data: { turbo_confirm: 'Are you sure you want to delete this page?' } %>
+
+
diff --git a/examples/wiki_cms/config/initializers/git_store.rb b/examples/wiki_cms/config/initializers/git_store.rb
new file mode 100644
index 0000000..557707c
--- /dev/null
+++ b/examples/wiki_cms/config/initializers/git_store.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+# GitStore configuration for Rails 8
+# This initializer sets up the GitStore connection for content storage
+
+Rails.application.config.to_prepare do
+ # Path to the content repository
+ content_repo_path = Rails.root.join('content_repo')
+
+ # Ensure the repository exists
+ unless File.exist?(content_repo_path.join('.git'))
+ FileUtils.mkdir_p(content_repo_path)
+ Dir.chdir(content_repo_path) do
+ # Use array form to avoid shell injection
+ system('git', 'init')
+ system('git', 'config', 'user.name', 'Wiki System')
+ system('git', 'config', 'user.email', 'wiki@example.com')
+ end
+ end
+
+ # Initialize the global GitStore instance
+ Rails.application.config.content_store = GitStore.new(content_repo_path.to_s)
+end
+
+# Helper method to access the store from anywhere
+module GitStoreHelper
+ def content_store
+ Rails.application.config.content_store
+ end
+end
diff --git a/examples/wiki_cms/config/routes.rb b/examples/wiki_cms/config/routes.rb
new file mode 100644
index 0000000..af4c5df
--- /dev/null
+++ b/examples/wiki_cms/config/routes.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+Rails.application.routes.draw do
+ # Wiki pages routes
+ resources :pages, param: :slug do
+ member do
+ get :history
+ get :diff
+ post :rollback
+ get :raw
+ end
+ collection do
+ get :recent
+ get :search
+ end
+ end
+
+ # Set wiki home as root
+ root 'pages#show', slug: 'home'
+end