#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) # --- Script Setup --- $scriptPath = $MyInvocation.MyCommand.Path $scriptDir = Split-Path -Path $scriptPath -Parent $scriptName = (Get-Item $scriptPath).Name Write-Host "Script: $scriptName at $scriptPath" Write-Host "Repository directory: $scriptDir" Write-Host "Sync frequency: Every $frequencyMinutes minutes" # --- Scheduled Task Setup --- $taskName = "FoamGitSync" $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., 100 years) instead of [TimeSpan]::MaxValue $practicallyIndefiniteDuration = New-TimeSpan -Days (365 * 100 + 25) # Account for leap years over 100 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 -Description $taskDescription -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