Automatically commit and push changes to GitHub with zero manual intervention. Features: - File watcher mode: Auto-detects changes in real-time - Timer mode: Commits at regular intervals (default 5 minutes) - Smart exclusions: Ignores temp files, sessions, cache, db files - Retry logic: Auto-retries failed pushes - Change summaries: Detailed commit messages with file lists Components: - auto-deploy.ps1: Core CD engine with file watcher - start-cd.ps1: Easy-to-use wrapper with commands - .cd-config.json: Configuration file - CONTINUOUS_DELIVERY_GUIDE.md: Complete documentation Usage: .\start-cd.ps1 watch # Start file watcher (recommended) .\start-cd.ps1 start # Timer mode (every 5 min) .\start-cd.ps1 once # One-time commit Also removed db_data/ from git tracking (now in .gitignore). Database runtime files should never be committed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
363 lines
11 KiB
PowerShell
363 lines
11 KiB
PowerShell
# Continuous Delivery Script for EasyStream
|
||
# Automatically commits and pushes changes to GitHub
|
||
|
||
param(
|
||
[int]$IntervalSeconds = 300, # Default: 5 minutes
|
||
[string]$Branch = "dev",
|
||
[string]$CommitPrefix = "auto:",
|
||
[switch]$WatchMode,
|
||
[switch]$Verbose
|
||
)
|
||
|
||
$ErrorActionPreference = "Continue"
|
||
$RepoPath = $PSScriptRoot
|
||
|
||
# Color output functions
|
||
function Write-Success { param($msg) Write-Host "✓ $msg" -ForegroundColor Green }
|
||
function Write-Info { param($msg) Write-Host "ℹ $msg" -ForegroundColor Cyan }
|
||
function Write-Warning { param($msg) Write-Host "⚠ $msg" -ForegroundColor Yellow }
|
||
function Write-Error { param($msg) Write-Host "✗ $msg" -ForegroundColor Red }
|
||
|
||
# Load configuration
|
||
$configPath = Join-Path $RepoPath ".cd-config.json"
|
||
if (Test-Path $configPath) {
|
||
$config = Get-Content $configPath | ConvertFrom-Json
|
||
if ($config.intervalSeconds) { $IntervalSeconds = $config.intervalSeconds }
|
||
if ($config.branch) { $Branch = $config.branch }
|
||
if ($config.commitPrefix) { $CommitPrefix = $config.commitPrefix }
|
||
if ($config.excludePatterns) { $excludePatterns = $config.excludePatterns }
|
||
} else {
|
||
$excludePatterns = @(
|
||
"f_data/data_sessions/*",
|
||
"f_data/data_cache/_c_tpl/*",
|
||
".setup_complete",
|
||
"*.log",
|
||
"db_data/*",
|
||
"node_modules/*",
|
||
"vendor/*"
|
||
)
|
||
}
|
||
|
||
function Test-GitRepo {
|
||
Push-Location $RepoPath
|
||
try {
|
||
$null = git rev-parse --git-dir 2>&1
|
||
return $?
|
||
} finally {
|
||
Pop-Location
|
||
}
|
||
}
|
||
|
||
function Get-GitStatus {
|
||
Push-Location $RepoPath
|
||
try {
|
||
$status = git status --porcelain 2>&1
|
||
return $status
|
||
} finally {
|
||
Pop-Location
|
||
}
|
||
}
|
||
|
||
function Get-ChangeSummary {
|
||
Push-Location $RepoPath
|
||
try {
|
||
$modified = @(git diff --name-only).Count
|
||
$staged = @(git diff --cached --name-only).Count
|
||
$untracked = @(git ls-files --others --exclude-standard).Count
|
||
|
||
return @{
|
||
Modified = $modified
|
||
Staged = $staged
|
||
Untracked = $untracked
|
||
Total = $modified + $staged + $untracked
|
||
}
|
||
} finally {
|
||
Pop-Location
|
||
}
|
||
}
|
||
|
||
function Update-GitIgnore {
|
||
$gitignorePath = Join-Path $RepoPath ".gitignore"
|
||
|
||
$ignoreContent = @"
|
||
# Temporary session files
|
||
f_data/data_sessions/sess_*
|
||
# Cache files
|
||
f_data/data_cache/_c_tpl/*
|
||
# Setup marker
|
||
.setup_complete
|
||
# Database runtime files
|
||
db_data/*
|
||
# Logs
|
||
*.log
|
||
# Dependencies
|
||
node_modules/
|
||
vendor/
|
||
# IDE
|
||
.vscode/
|
||
.idea/
|
||
"@
|
||
|
||
if (-not (Test-Path $gitignorePath)) {
|
||
$ignoreContent | Out-File -FilePath $gitignorePath -Encoding UTF8
|
||
Write-Success "Created .gitignore"
|
||
} else {
|
||
# Append if patterns are missing
|
||
$existing = Get-Content $gitignorePath -Raw
|
||
if ($existing -notmatch "f_data/data_sessions/sess_\*") {
|
||
"`n# Auto-generated exclusions`n$ignoreContent" | Add-Content -Path $gitignorePath
|
||
Write-Success "Updated .gitignore"
|
||
}
|
||
}
|
||
}
|
||
|
||
function Invoke-AutoCommit {
|
||
param([string]$message)
|
||
|
||
Push-Location $RepoPath
|
||
try {
|
||
Write-Info "Checking for changes..."
|
||
|
||
$changes = Get-ChangeSummary
|
||
|
||
if ($changes.Total -eq 0) {
|
||
if ($Verbose) { Write-Info "No changes detected" }
|
||
return $false
|
||
}
|
||
|
||
Write-Info "Found $($changes.Total) changed files (Modified: $($changes.Modified), Staged: $($changes.Staged), Untracked: $($changes.Untracked))"
|
||
|
||
# Stage all changes
|
||
Write-Info "Staging changes..."
|
||
git add -A 2>&1 | Out-Null
|
||
|
||
# Generate commit message if not provided
|
||
if (-not $message) {
|
||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||
$message = "${CommitPrefix} Update at $timestamp"
|
||
|
||
# Add file change summary
|
||
$fileList = @(git diff --cached --name-only | Select-Object -First 5)
|
||
if ($fileList.Count -gt 0) {
|
||
$message += "`n`nChanged files:"
|
||
foreach ($file in $fileList) {
|
||
$message += "`n- $file"
|
||
}
|
||
if ($changes.Total -gt 5) {
|
||
$message += "`n- ... and $($changes.Total - 5) more files"
|
||
}
|
||
}
|
||
|
||
$message += "`n`n🤖 Generated with Claude Code Continuous Delivery`n`nCo-Authored-By: Claude <noreply@anthropic.com>"
|
||
}
|
||
|
||
# Create commit
|
||
Write-Info "Creating commit..."
|
||
$commitResult = git commit -m $message 2>&1
|
||
|
||
if ($LASTEXITCODE -eq 0) {
|
||
Write-Success "Committed changes"
|
||
return $true
|
||
} else {
|
||
Write-Warning "Commit failed or nothing to commit"
|
||
if ($Verbose) { Write-Host $commitResult }
|
||
return $false
|
||
}
|
||
} finally {
|
||
Pop-Location
|
||
}
|
||
}
|
||
|
||
function Invoke-AutoPush {
|
||
Push-Location $RepoPath
|
||
try {
|
||
Write-Info "Pushing to origin/$Branch..."
|
||
|
||
# Check if we're ahead of remote
|
||
$ahead = git rev-list --count "origin/$Branch..$Branch" 2>&1
|
||
|
||
if ($ahead -match "^\d+$" -and [int]$ahead -gt 0) {
|
||
Write-Info "Local is $ahead commit(s) ahead of remote"
|
||
|
||
# Push with retry logic
|
||
$maxRetries = 3
|
||
$retryCount = 0
|
||
|
||
while ($retryCount -lt $maxRetries) {
|
||
$pushResult = git push origin $Branch 2>&1
|
||
|
||
if ($LASTEXITCODE -eq 0) {
|
||
Write-Success "Successfully pushed to GitHub"
|
||
return $true
|
||
} else {
|
||
$retryCount++
|
||
Write-Warning "Push attempt $retryCount failed"
|
||
if ($Verbose) { Write-Host $pushResult }
|
||
|
||
if ($retryCount -lt $maxRetries) {
|
||
Write-Info "Retrying in 5 seconds..."
|
||
Start-Sleep -Seconds 5
|
||
}
|
||
}
|
||
}
|
||
|
||
Write-Error "Failed to push after $maxRetries attempts"
|
||
return $false
|
||
} else {
|
||
if ($Verbose) { Write-Info "Already up to date with remote" }
|
||
return $false
|
||
}
|
||
} finally {
|
||
Pop-Location
|
||
}
|
||
}
|
||
|
||
function Start-ContinuousDelivery {
|
||
Write-Info "========================================="
|
||
Write-Info "EasyStream Continuous Delivery Started"
|
||
Write-Info "========================================="
|
||
Write-Info "Repository: $RepoPath"
|
||
Write-Info "Branch: $Branch"
|
||
Write-Info "Interval: $IntervalSeconds seconds"
|
||
Write-Info "========================================="
|
||
Write-Info ""
|
||
|
||
# Verify git repository
|
||
if (-not (Test-GitRepo)) {
|
||
Write-Error "Not a git repository: $RepoPath"
|
||
exit 1
|
||
}
|
||
|
||
# Update .gitignore
|
||
Update-GitIgnore
|
||
|
||
$iteration = 0
|
||
|
||
while ($true) {
|
||
$iteration++
|
||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||
|
||
Write-Host "`n[$timestamp] Check #$iteration" -ForegroundColor Magenta
|
||
|
||
try {
|
||
# Commit changes
|
||
$committed = Invoke-AutoCommit
|
||
|
||
# Push if there was a commit
|
||
if ($committed) {
|
||
Start-Sleep -Seconds 2
|
||
Invoke-AutoPush
|
||
}
|
||
|
||
# Wait for next interval
|
||
Write-Info "Next check in $IntervalSeconds seconds... (Press Ctrl+C to stop)"
|
||
Start-Sleep -Seconds $IntervalSeconds
|
||
|
||
} catch {
|
||
Write-Error "Error during CD cycle: $_"
|
||
Write-Info "Continuing in 30 seconds..."
|
||
Start-Sleep -Seconds 30
|
||
}
|
||
}
|
||
}
|
||
|
||
function Start-FileWatcher {
|
||
Write-Info "========================================="
|
||
Write-Info "EasyStream File Watcher Started"
|
||
Write-Info "========================================="
|
||
Write-Info "Watching: $RepoPath"
|
||
Write-Info "Branch: $Branch"
|
||
Write-Info "Debounce: 30 seconds after last change"
|
||
Write-Info "========================================="
|
||
Write-Info ""
|
||
|
||
# Verify git repository
|
||
if (-not (Test-GitRepo)) {
|
||
Write-Error "Not a git repository: $RepoPath"
|
||
exit 1
|
||
}
|
||
|
||
# Update .gitignore
|
||
Update-GitIgnore
|
||
|
||
# Create file system watcher
|
||
$watcher = New-Object System.IO.FileSystemWatcher
|
||
$watcher.Path = $RepoPath
|
||
$watcher.IncludeSubdirectories = $true
|
||
$watcher.EnableRaisingEvents = $true
|
||
|
||
# Exclude patterns
|
||
$watcher.Filter = "*.*"
|
||
$watcher.NotifyFilter = [System.IO.NotifyFilters]::FileName -bor
|
||
[System.IO.NotifyFilters]::DirectoryName -bor
|
||
[System.IO.NotifyFilters]::LastWrite
|
||
|
||
$global:lastChangeTime = Get-Date
|
||
$global:changedFiles = @{}
|
||
|
||
$onChange = {
|
||
param($sender, $e)
|
||
|
||
# Skip excluded patterns
|
||
$relativePath = $e.FullPath.Replace($RepoPath, "").TrimStart('\', '/')
|
||
$exclude = $false
|
||
foreach ($pattern in $excludePatterns) {
|
||
if ($relativePath -like $pattern) {
|
||
$exclude = $true
|
||
break
|
||
}
|
||
}
|
||
|
||
if (-not $exclude) {
|
||
$global:lastChangeTime = Get-Date
|
||
$global:changedFiles[$e.FullPath] = $e.ChangeType
|
||
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] " -NoNewline -ForegroundColor Gray
|
||
Write-Host "$($e.ChangeType): " -NoNewline -ForegroundColor Yellow
|
||
Write-Host $relativePath -ForegroundColor White
|
||
}
|
||
}
|
||
|
||
# Register events
|
||
Register-ObjectEvent -InputObject $watcher -EventName Changed -Action $onChange | Out-Null
|
||
Register-ObjectEvent -InputObject $watcher -EventName Created -Action $onChange | Out-Null
|
||
Register-ObjectEvent -InputObject $watcher -EventName Deleted -Action $onChange | Out-Null
|
||
Register-ObjectEvent -InputObject $watcher -EventName Renamed -Action $onChange | Out-Null
|
||
|
||
Write-Success "File watcher active. Monitoring for changes..."
|
||
Write-Info "Changes will auto-commit 30 seconds after last modification"
|
||
Write-Info ""
|
||
|
||
try {
|
||
while ($true) {
|
||
Start-Sleep -Seconds 5
|
||
|
||
# Check if enough time has passed since last change
|
||
$timeSinceLastChange = (Get-Date) - $global:lastChangeTime
|
||
|
||
if ($global:changedFiles.Count -gt 0 -and $timeSinceLastChange.TotalSeconds -ge 30) {
|
||
Write-Info "`nDebounce period elapsed. Processing $($global:changedFiles.Count) changes..."
|
||
|
||
$committed = Invoke-AutoCommit
|
||
if ($committed) {
|
||
Start-Sleep -Seconds 2
|
||
Invoke-AutoPush
|
||
}
|
||
|
||
# Reset
|
||
$global:changedFiles = @{}
|
||
Write-Info "`nContinuing to watch for changes...`n"
|
||
}
|
||
}
|
||
} finally {
|
||
$watcher.Dispose()
|
||
Get-EventSubscriber | Unregister-Event
|
||
}
|
||
}
|
||
|
||
# Main execution
|
||
if ($WatchMode) {
|
||
Start-FileWatcher
|
||
} else {
|
||
Start-ContinuousDelivery
|
||
}
|