|
| 1 | +// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details. |
| 2 | + |
| 3 | +using JetBrains.Annotations; |
| 4 | +using PostSharp.Engineering.BuildTools.Build.Model; |
| 5 | +using PostSharp.Engineering.BuildTools.Utilities; |
| 6 | +using System; |
| 7 | +using System.IO; |
| 8 | +using System.IO.Compression; |
| 9 | +using System.Linq; |
| 10 | + |
| 11 | +namespace PostSharp.Engineering.BuildTools.Build.Publishing; |
| 12 | + |
| 13 | +/// <summary> |
| 14 | +/// Publishes a zip file to a git repository by cloning the repo, replacing its content with the zip file content, |
| 15 | +/// then committing and pushing. |
| 16 | +/// </summary> |
| 17 | +[PublicAPI] |
| 18 | +public class GitRepoPublisher : ArtifactPublisher |
| 19 | +{ |
| 20 | + private readonly string _gitHubUrl; |
| 21 | + private readonly string _commitMessage; |
| 22 | + |
| 23 | + /// <summary> |
| 24 | + /// Initializes a new instance of the <see cref="GitRepoPublisher"/> class. |
| 25 | + /// </summary> |
| 26 | + /// <param name="files">The pattern matching the zip file(s) to publish.</param> |
| 27 | + /// <param name="gitHubUrl">The GitHub repository URL to publish to.</param> |
| 28 | + /// <param name="commitMessage">The commit message to use when pushing changes.</param> |
| 29 | + public GitRepoPublisher( Pattern files, string gitHubUrl, string commitMessage ) : base( files ) |
| 30 | + { |
| 31 | + this._gitHubUrl = gitHubUrl; |
| 32 | + this._commitMessage = commitMessage; |
| 33 | + } |
| 34 | + |
| 35 | + public override SuccessCode PublishFile( |
| 36 | + BuildContext context, |
| 37 | + PublishSettings settings, |
| 38 | + string file, |
| 39 | + BuildArguments buildArguments, |
| 40 | + BuildConfigurationInfo configuration ) |
| 41 | + { |
| 42 | + var console = context.Console; |
| 43 | + |
| 44 | + console.WriteMessage( $"Publishing '{file}' to git repository '{this._gitHubUrl}'." ); |
| 45 | + |
| 46 | + // Expand environment variables in the URL and commit message. |
| 47 | + var gitHubUrl = Environment.ExpandEnvironmentVariables( this._gitHubUrl ); |
| 48 | + var commitMessage = Environment.ExpandEnvironmentVariables( this._commitMessage ); |
| 49 | + |
| 50 | + if ( string.IsNullOrEmpty( gitHubUrl ) ) |
| 51 | + { |
| 52 | + console.WriteError( "The GitHub URL is empty or the environment variable is not defined." ); |
| 53 | + |
| 54 | + return SuccessCode.Fatal; |
| 55 | + } |
| 56 | + |
| 57 | + // Create a temporary directory for cloning. |
| 58 | + var tempDirectory = Path.Combine( Path.GetTempPath(), $"GitRepoPublisher_{Guid.NewGuid():N}" ); |
| 59 | + |
| 60 | + try |
| 61 | + { |
| 62 | + Directory.CreateDirectory( tempDirectory ); |
| 63 | + |
| 64 | + // Clone the repository (shallow clone of default branch). |
| 65 | + console.WriteImportantMessage( $"Cloning repository '{gitHubUrl}' to '{tempDirectory}'." ); |
| 66 | + |
| 67 | + if ( !ToolInvocationHelper.InvokeTool( |
| 68 | + console, |
| 69 | + "git", |
| 70 | + $"clone --depth 1 \"{gitHubUrl}\" .", |
| 71 | + tempDirectory ) ) |
| 72 | + { |
| 73 | + console.WriteError( "Failed to clone the repository." ); |
| 74 | + |
| 75 | + return SuccessCode.Fatal; |
| 76 | + } |
| 77 | + |
| 78 | + // Delete all files in the repo except .git directory. |
| 79 | + console.WriteMessage( "Removing existing content from the repository." ); |
| 80 | + |
| 81 | + foreach ( var entry in Directory.EnumerateFileSystemEntries( tempDirectory ) ) |
| 82 | + { |
| 83 | + var name = Path.GetFileName( entry ); |
| 84 | + |
| 85 | + if ( name.Equals( ".git", StringComparison.OrdinalIgnoreCase ) ) |
| 86 | + { |
| 87 | + continue; |
| 88 | + } |
| 89 | + |
| 90 | + if ( Directory.Exists( entry ) ) |
| 91 | + { |
| 92 | + Directory.Delete( entry, true ); |
| 93 | + } |
| 94 | + else |
| 95 | + { |
| 96 | + File.Delete( entry ); |
| 97 | + } |
| 98 | + } |
| 99 | + |
| 100 | + // Extract the zip file content to the repo directory. |
| 101 | + console.WriteImportantMessage( $"Extracting '{file}' to repository." ); |
| 102 | + |
| 103 | + ZipFile.ExtractToDirectory( file, tempDirectory, true ); |
| 104 | + |
| 105 | + // Check if there are any changes. |
| 106 | + if ( !ToolInvocationHelper.InvokeTool( |
| 107 | + console, |
| 108 | + "git", |
| 109 | + "status --porcelain", |
| 110 | + tempDirectory, |
| 111 | + out _, |
| 112 | + out var statusOutput ) ) |
| 113 | + { |
| 114 | + console.WriteError( "Failed to get git status." ); |
| 115 | + |
| 116 | + return SuccessCode.Fatal; |
| 117 | + } |
| 118 | + |
| 119 | + var changes = statusOutput.Split( '\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries ); |
| 120 | + |
| 121 | + if ( changes.Length == 0 ) |
| 122 | + { |
| 123 | + console.WriteSuccess( "No changes to commit. Repository is up to date." ); |
| 124 | + |
| 125 | + return SuccessCode.Success; |
| 126 | + } |
| 127 | + |
| 128 | + console.WriteMessage( $"Found {changes.Length} changes to commit." ); |
| 129 | + |
| 130 | + // Stage all changes. |
| 131 | + if ( !ToolInvocationHelper.InvokeTool( |
| 132 | + console, |
| 133 | + "git", |
| 134 | + "add -A", |
| 135 | + tempDirectory ) ) |
| 136 | + { |
| 137 | + console.WriteError( "Failed to stage changes." ); |
| 138 | + |
| 139 | + return SuccessCode.Fatal; |
| 140 | + } |
| 141 | + |
| 142 | + // Commit the changes. |
| 143 | + // Escape double quotes in the commit message. |
| 144 | + var escapedMessage = commitMessage.Replace( "\"", "\\\"", StringComparison.Ordinal ); |
| 145 | + |
| 146 | + if ( !ToolInvocationHelper.InvokeTool( |
| 147 | + console, |
| 148 | + "git", |
| 149 | + $"commit -m \"{escapedMessage}\"", |
| 150 | + tempDirectory ) ) |
| 151 | + { |
| 152 | + console.WriteError( "Failed to commit changes." ); |
| 153 | + |
| 154 | + return SuccessCode.Fatal; |
| 155 | + } |
| 156 | + |
| 157 | + // Push to remote (skip in dry mode). |
| 158 | + if ( settings.Dry ) |
| 159 | + { |
| 160 | + console.WriteImportantMessage( "Dry run: Skipping push to remote repository." ); |
| 161 | + console.WriteSuccess( $"Dry run completed successfully for '{file}' to '{gitHubUrl}'." ); |
| 162 | + } |
| 163 | + else |
| 164 | + { |
| 165 | + console.WriteImportantMessage( "Pushing changes to remote repository." ); |
| 166 | + |
| 167 | + if ( !ToolInvocationHelper.InvokeTool( |
| 168 | + console, |
| 169 | + "git", |
| 170 | + "push", |
| 171 | + tempDirectory ) ) |
| 172 | + { |
| 173 | + console.WriteError( "Failed to push changes to remote repository." ); |
| 174 | + |
| 175 | + return SuccessCode.Fatal; |
| 176 | + } |
| 177 | + |
| 178 | + console.WriteSuccess( $"Successfully published '{file}' to '{gitHubUrl}'." ); |
| 179 | + } |
| 180 | + |
| 181 | + return SuccessCode.Success; |
| 182 | + } |
| 183 | + catch ( Exception e ) |
| 184 | + { |
| 185 | + console.WriteError( $"Error publishing to git repository: {e.Message}" ); |
| 186 | + |
| 187 | + return SuccessCode.Fatal; |
| 188 | + } |
| 189 | + finally |
| 190 | + { |
| 191 | + // Clean up the temporary directory. |
| 192 | + try |
| 193 | + { |
| 194 | + if ( Directory.Exists( tempDirectory ) ) |
| 195 | + { |
| 196 | + // Reset read-only attributes on .git files before deleting. |
| 197 | + foreach ( var filePath in Directory.EnumerateFiles( tempDirectory, "*", SearchOption.AllDirectories ) ) |
| 198 | + { |
| 199 | + File.SetAttributes( filePath, FileAttributes.Normal ); |
| 200 | + } |
| 201 | + |
| 202 | + Directory.Delete( tempDirectory, true ); |
| 203 | + } |
| 204 | + } |
| 205 | + catch ( Exception e ) |
| 206 | + { |
| 207 | + console.WriteWarning( $"Failed to clean up temporary directory '{tempDirectory}': {e.Message}" ); |
| 208 | + } |
| 209 | + } |
| 210 | + } |
| 211 | +} |
0 commit comments