Files
homefoam/foam_sync.ps1
2025-06-19 01:06:29 -06:00

315 lines
15 KiB
PowerShell

#Requires -Version 5.1
<#
.SYNOPSIS
A PowerShell script to periodically synchronize a Git repository, similar to the foam_sync.sh bash script.
It stages all changes, commits if there are any, and then syncs with the 'origin/main' remote.
It also attempts to set up a Windows Scheduled Task to run itself periodically.
.DESCRIPTION
This script performs the following actions:
1. Configures a Scheduled Task to run this script at a defined frequency.
- The first time this script is run, it might require Administrator privileges to register the task.
2. Navigates to the script's directory (assumed to be the Git repository root).
3. Stages all changes using 'git add .'.
4. Checks for actual staged changes using 'git diff --staged --quiet'.
5. If changes exist, commits them with a timestamped message.
6. If no meaningful changes were staged, resets the staging area.
7. Updates the local knowledge of the remote 'origin'.
8. Compares local HEAD with 'origin/main'.
9. If they differ, it sleeps for a random interval to avoid race conditions.
10. Determines if local is ahead, remote is ahead, or they have diverged.
11. Performs 'git pull --rebase' or 'git push' accordingly.
12. In case of divergence, it attempts a rebase, preferring the newer commit (based on timestamp)
or 'ours' if timestamps are equal. Then pushes.
.NOTES
Author: Gemini Code Assist (Translated from bash)
Version: 1.0
Prerequisites: Git must be installed and in the system PATH.
Running for the first time: You may need to run this script as an Administrator
to allow the Scheduled Task to be registered.
#>
# --- Configuration ---
$frequencyMinutes = 2 # How often the Scheduled Task should attempt to run this script
$executionTimeLimitBufferSeconds = 30 # Buffer: task stops if it runs longer than (frequency - buffer)
# --- Scheduled Task Setup ---
$taskName = "FoamGitSync"
# --- Log File Setup ---
# Log inside the repository, in a .logs subfolder. Ensure this is in .gitignore
$scriptDirForLog = $PSScriptRoot # Use PSScriptRoot for robustness in determining script's dir
$logDir = Join-Path -Path $scriptDirForLog -ChildPath ".logs"
if (-not (Test-Path -Path $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
}
$logFilePath = Join-Path -Path $logDir -ChildPath "foam_sync.log"
Start-Transcript -Path $logFilePath -Append -IncludeInvocationHeader -Force
try {
# Wrap main script logic in try for Stop-Transcript in finally
# --- Script Setup ---
$scriptPath = $MyInvocation.MyCommand.Path
$scriptDir = Split-Path -Path $scriptPath -Parent # This is the Git repository root
$scriptName = (Get-Item $scriptPath).Name
Write-Host "Script: $scriptName at $scriptPath"
Write-Host "Repository directory: $scriptDir"
Write-Host "Sync frequency: Every $frequencyMinutes minutes"
Write-Host "Log file: $logFilePath"
$taskDescription = "Periodically synchronizes the Git repository at $scriptDir using $scriptName."
# Run as the user who executes this script.
$taskPrincipal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive
# Define a very long duration (e.g., 40 years)
$practicallyIndefiniteDuration = New-TimeSpan -Days (365 * 40 + 10) # Approx 40 years, accounting for leap years
# Trigger configuration
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes $frequencyMinutes) -RepetitionDuration $practicallyIndefiniteDuration
# Action configuration:
# We use an indirection technique to ensure the window is truly hidden.
# The scheduled task launches an initial PowerShell.
# This initial PowerShell then uses Start-Process to launch the *actual* script in a new, hidden PowerShell process.
# 1. Innermost PowerShell's -File argument: Path to the script, with internal double quotes escaped as ""
# e.g., \"C:\path\to your script.ps1\"
$scriptPathForInnermostFileParam = $scriptPath.Replace('"', '""')
$innermostFileArg = "\`"$scriptPathForInnermostFileParam\`""
# 2. Argument string for the innermost PowerShell instance (the one executing the actual script)
# e.g., -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"C:\path\to your script.ps1\"
$innermostPSArgs = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File $innermostFileArg"
# 3. The innermostPSArgs string needs its single quotes escaped (as '') because it will be wrapped in single quotes for Start-Process's -ArgumentList
$innermostPSArgsEscapedForStartProcess = $innermostPSArgs.Replace("'", "''")
# 4. Command string that the first PowerShell instance (launched by Task Scheduler) will execute using Start-Process.
# e.g., Start-Process -FilePath powershell.exe -ArgumentList '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"C:\path\to your script.ps1\"' -WindowStyle Hidden
$commandToRunViaStartProcess = "Start-Process -FilePath powershell.exe -ArgumentList '$innermostPSArgsEscapedForStartProcess' -WindowStyle Hidden"
# 5. Argument string FOR THE TASK SCHEDULER to pass to the first powershell.exe.
# The $commandToRunViaStartProcess is the value for -Command, and needs to be quoted for registration.
# e.g., -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "Start-Process -FilePath powershell.exe -ArgumentList '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"C:\path\to your script.ps1\"' -WindowStyle Hidden"
$actionArgumentForRegistration = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command \`"$commandToRunViaStartProcess\`""
# 6. Expected argument string WHEN RETRIEVED by Get-ScheduledTask.
# Task Scheduler/PowerShell often strips the outermost quotes from the -Command value when retrieved.
$expectedRetrievedActionArgument = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command $commandToRunViaStartProcess"
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $actionArgumentForRegistration
# Task settings
# Calculate a realistic execution time limit based on the frequency
$executionTimeLimitTotalSeconds = ($frequencyMinutes * 60) - $executionTimeLimitBufferSeconds
if ($executionTimeLimitTotalSeconds -lt 30) {
# Ensure a minimum sensible execution time (e.g., 30 seconds)
$executionTimeLimitTotalSeconds = 30
}
$taskExecutionTimeLimit = New-TimeSpan -Seconds $executionTimeLimitTotalSeconds
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit $taskExecutionTimeLimit
# Check and configure the scheduled task
try {
$existingTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
if ($existingTask) {
Write-Host "Scheduled task '$taskName' already exists. Checking configuration..."
$currentTrigger = $existingTask.Triggers[0]
$currentAction = $existingTask.Actions[0]
# You could also compare $existingTask.Principal.UserId with $taskPrincipal.UserId
# and $existingTask.Description with $taskDescription if strict matching is needed for those.
# Check Trigger
$triggerMatches = $false
if ($currentTrigger -is [Microsoft.Management.Infrastructure.CimInstance] `
-and $currentTrigger.RepetitionInterval.TotalMinutes -eq $frequencyMinutes `
-and $currentTrigger.RepetitionDuration -eq $practicallyIndefiniteDuration) {
$triggerMatches = $true
}
# Check Action
$actionMatches = $false
if ($currentAction -is [Microsoft.Management.Infrastructure.CimInstance] `
-and $currentAction.Execute -eq "powershell.exe" `
-and $currentAction.Argument -eq $expectedRetrievedActionArgument) {
$actionMatches = $true
}
# Check Principal (example, can be expanded)
$principalMatches = ($existingTask.Principal.UserId -eq $taskPrincipal.UserId)
# Check Settings (specifically ExecutionTimeLimit for this change)
$settingsMatch = $false
if ($existingTask.Settings.ExecutionTimeLimit -eq $taskExecutionTimeLimit) {
$settingsMatch = $true
}
if ($triggerMatches -and $actionMatches -and $principalMatches -and $settingsMatch) {
Write-Host "Scheduled task '$taskName' is already correctly configured."
}
else {
Write-Host "Scheduled task '$taskName' configuration differs. Attempting to update in-place..."
try {
Set-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Settings $settings -Principal $taskPrincipal -ErrorAction Stop
Write-Host "Scheduled task '$taskName' updated successfully."
}
catch {
Write-Warning "Failed to update scheduled task '$taskName' in-place. Error: $($_.Exception.Message)"
Write-Warning "The task remains in its previous state. Manual intervention may be required or re-run with Administrator privileges."
# We intentionally DO NOT unregister here to avoid the scenario you described.
}
}
}
else {
Write-Host "Creating scheduled task '$taskName'..."
try {
Register-ScheduledTask -TaskName $taskName -Description $taskDescription -Principal $taskPrincipal -Trigger $trigger -Action $action -Settings $settings -ErrorAction Stop
Write-Host "Scheduled task '$taskName' created successfully."
}
catch {
Write-Warning "Failed to create scheduled task '$taskName'. Error: $($_.Exception.Message)"
Write-Warning "You may need to run this script as Administrator."
}
}
}
catch {
# This outer catch is for unexpected errors, e.g., if Get-ScheduledTask had -ErrorAction Stop
Write-Warning "An unexpected error occurred during scheduled task setup for '$taskName'. Error: $($_.Exception.Message)"
}
# --- Git Operations ---
Write-Host "Navigating to repository: $scriptDir"
try {
Set-Location -Path $scriptDir -ErrorAction Stop
}
catch {
Write-Error "Unable to find repository at $scriptDir. Exiting script."
exit 1
}
Write-Host "Staging all changes with 'git add .'"
git add .
if ($LASTEXITCODE -ne 0) {
Write-Warning "'git add .' command failed with exit code $LASTEXITCODE."
}
Write-Host "Checking for staged changes with 'git diff --staged --quiet'..."
git diff --staged --quiet
$changesStaged = ($LASTEXITCODE -ne 0)
if ($changesStaged) {
Write-Host "Staged changes detected. Creating commit..."
$commitMessage = "$scriptName ($($env:COMPUTERNAME)) $(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ')"
Write-Host "Commit message: $commitMessage"
git commit -m $commitMessage
if ($LASTEXITCODE -ne 0) {
Write-Warning "'git commit' command failed with exit code $LASTEXITCODE."
}
else {
Write-Host "Commit created successfully."
}
}
else {
Write-Host "No relevant changes detected to commit."
Write-Host "Resetting staging area with 'git reset HEAD --quiet'."
git reset HEAD --quiet
}
Write-Host "Updating remote 'origin' with 'git remote update origin --prune'..."
git remote update origin --prune
if ($LASTEXITCODE -ne 0) {
Write-Error "Unable to update remote 'origin'. Exiting script."
exit 1
}
$localCommit = (git rev-parse HEAD 2>$null).Trim()
if ($LASTEXITCODE -ne 0 -or -not $localCommit) { Write-Error "Failed to get local HEAD commit. Exiting script."; exit 1 }
$remoteBranch = "origin/main"
$remoteCommit = (git rev-parse $remoteBranch 2>$null).Trim()
if ($LASTEXITCODE -ne 0 -or -not $remoteCommit) { Write-Error "Failed to get remote '$remoteBranch' commit. Exiting script."; exit 1 }
Write-Host "Local HEAD commit: $localCommit"
Write-Host "Remote '$remoteBranch' commit: $remoteCommit"
if ($localCommit -eq $remoteCommit) {
Write-Host "Local and remote are already in sync."
exit 0
}
$sleepyTime = Get-Random -Minimum 1 -Maximum 15
Write-Host "Local and remote differ. Sleeping for $sleepyTime seconds..."
Start-Sleep -Seconds $sleepyTime
$localCommitAfterSleep = (git rev-parse HEAD 2>$null).Trim()
if ($LASTEXITCODE -ne 0 -or -not $localCommitAfterSleep) { Write-Error "Failed to re-get local HEAD commit. Exiting script."; exit 1 }
$remoteCommitAfterSleep = (git rev-parse $remoteBranch 2>$null).Trim()
if ($LASTEXITCODE -ne 0 -or -not $remoteCommitAfterSleep) { Write-Error "Failed to re-get remote '$remoteBranch' commit. Exiting script."; exit 1 }
if ($localCommitAfterSleep -eq $remoteCommitAfterSleep) {
Write-Host "Local and remote became synchronized during sleep."
exit 0
}
$localCommit = $localCommitAfterSleep
$remoteCommit = $remoteCommitAfterSleep
Write-Host "Proceeding with sync logic..."
git merge-base --is-ancestor $localCommit $remoteCommit
$localIsAncestorOfRemote = ($LASTEXITCODE -eq 0)
git merge-base --is-ancestor $remoteCommit $localCommit
$remoteIsAncestorOfLocal = ($LASTEXITCODE -eq 0)
if ($localIsAncestorOfRemote) {
Write-Host "Remote '$remoteBranch' is ahead. Pulling with rebase..."
git pull --rebase origin main
if ($LASTEXITCODE -ne 0) { Write-Error "'git pull --rebase' failed. Manual intervention may be required."; exit 1 }
}
elseif ($remoteIsAncestorOfLocal) {
Write-Host "Local HEAD is ahead. Pushing..."
git push origin main
if ($LASTEXITCODE -ne 0) { Write-Error "'git push' failed. Manual intervention may be required."; exit 1 }
}
else {
Write-Host "Local HEAD and remote '$remoteBranch' have diverged."
$remoteTimestampStr = (git log --pretty=format:"%at" -n 1 $remoteBranch 2>$null).Trim()
if ($LASTEXITCODE -ne 0 -or -not $remoteTimestampStr) { Write-Error "Failed to get remote commit timestamp for '$remoteBranch'. Exiting script."; exit 1 }
$remoteTimestamp = [long]$remoteTimestampStr
$localTimestampStr = (git log --pretty=format:"%at" -n 1 HEAD 2>$null).Trim()
if ($LASTEXITCODE -ne 0 -or -not $localTimestampStr) { Write-Error "Failed to get local commit timestamp for HEAD. Exiting script."; exit 1 }
$localTimestamp = [long]$localTimestampStr
# It's good practice to check if conversion was successful, though [long] will error on failure.
Write-Host "Local timestamp: $localTimestamp, Remote timestamp: $remoteTimestamp"
if ($remoteTimestamp -gt $localTimestamp) {
Write-Host "Remote is newer. Pulling with rebase, strategy 'theirs'..."
git pull --rebase -X theirs origin main
}
else {
Write-Host "Local is newer or same age. Pulling with rebase, strategy 'ours'..."
git pull --rebase -X ours origin main
}
if ($LASTEXITCODE -ne 0) { Write-Error "Rebase during divergence failed. Manual intervention may be required."; exit 1 }
Write-Host "Pushing changes after rebase..."
git push origin main
if ($LASTEXITCODE -ne 0) { Write-Error "'git push' after rebase failed. Manual intervention may be required."; exit 1 }
}
Write-Host "Synchronization process completed successfully."
exit 0
}
finally {
Stop-Transcript
}