Skip to content

Commit 5011441

Browse files
committed
support gitignore like file for excluding files from deployment. Closes #341
1 parent 6498df4 commit 5011441

File tree

7 files changed

+383
-39
lines changed

7 files changed

+383
-39
lines changed

src/Azure.Functions.Cli/Actions/AzureActions/PublishFunctionApp.cs

+124-39
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
using Azure.Functions.Cli.Interfaces;
1616
using Colors.Net;
1717
using Fclp;
18-
using Microsoft.Azure.WebJobs.Script;
1918
using static Azure.Functions.Cli.Common.OutputTheme;
2019

2120
namespace Azure.Functions.Cli.Actions.AzureActions
@@ -29,6 +28,8 @@ internal class PublishFunctionApp : BaseFunctionAppAction
2928
public bool PublishLocalSettings { get; set; }
3029
public bool OverwriteSettings { get; set; }
3130
public bool PublishLocalSettingsOnly { get; set; }
31+
public bool ListIgnoredFiles { get; set; }
32+
public bool ListIncludedFiles { get; set; }
3233

3334
public PublishFunctionApp(IArmManager armManager, ISettings settings, ISecretsManager secretsManager)
3435
: base(armManager)
@@ -51,62 +52,143 @@ public override ICommandLineParserResult ParseArgs(string[] args)
5152
.Setup<bool>('y', "overwrite-settings")
5253
.WithDescription("Only to be used in conjunction with -i or -o. Overwrites AppSettings in Azure with local value if different. Default is prompt.")
5354
.Callback(f => OverwriteSettings = f);
55+
Parser
56+
.Setup<bool>("list-ignored-files")
57+
.WithDescription("Displays a list of files that will be ignored from publishing based on .funcignore")
58+
.Callback(f => ListIgnoredFiles = f);
59+
Parser
60+
.Setup<bool>("list-included-files")
61+
.WithDescription("Displays a list of files that will be included in publishing based on .funcignore")
62+
.Callback(f => ListIncludedFiles = f);
5463

5564
return base.ParseArgs(args);
5665
}
5766

5867
public override async Task RunAsync()
5968
{
60-
ColoredConsole.WriteLine("Getting site publishing info...");
61-
var functionApp = await _armManager.GetFunctionAppAsync(FunctionAppName);
62-
if (PublishLocalSettingsOnly)
69+
GitIgnoreParser ignoreParser = null;
70+
try
6371
{
64-
var isSuccessful = await PublishAppSettings(functionApp);
65-
if (!isSuccessful)
72+
var path = Path.Combine(Environment.CurrentDirectory, Constants.FuncIgnoreFile);
73+
if (FileSystemHelpers.FileExists(path))
6674
{
67-
return;
75+
ignoreParser = new GitIgnoreParser(FileSystemHelpers.ReadAllTextFromFile(path));
6876
}
6977
}
78+
catch { }
79+
80+
if (ListIncludedFiles)
81+
{
82+
InternalListIncludedFiles(ignoreParser);
83+
}
84+
else if (ListIgnoredFiles)
85+
{
86+
InternalListIgnoredFiles(ignoreParser);
87+
}
7088
else
7189
{
72-
var functionAppRoot = ScriptHostHelpers.GetFunctionAppRootDirectory(Environment.CurrentDirectory);
73-
ColoredConsole.WriteLine(WarningColor($"Publish {functionAppRoot} contents to an Azure Function App. Locally deleted files are not removed from destination."));
74-
await RetryHelper.Retry(async () =>
90+
if (PublishLocalSettingsOnly)
7591
{
76-
using (var client = await GetRemoteZipClient(new Uri($"https://{functionApp.ScmUri}")))
77-
using (var request = new HttpRequestMessage(HttpMethod.Put, new Uri("api/zip/site/wwwroot", UriKind.Relative)))
78-
{
79-
request.Headers.IfMatch.Add(EntityTagHeaderValue.Any);
92+
await InternalPublishLocalSettingsOnly();
93+
}
94+
else
95+
{
96+
await InternalPublishFunctionApp(ignoreParser);
97+
}
98+
}
99+
}
100+
101+
private async Task InternalPublishFunctionApp(GitIgnoreParser ignoreParser)
102+
{
103+
ColoredConsole.WriteLine("Getting site publishing info...");
104+
var functionApp = await _armManager.GetFunctionAppAsync(FunctionAppName);
105+
var functionAppRoot = ScriptHostHelpers.GetFunctionAppRootDirectory(Environment.CurrentDirectory);
106+
ColoredConsole.WriteLine(WarningColor($"Publish {functionAppRoot} contents to an Azure Function App. Locally deleted files are not removed from destination."));
107+
await RetryHelper.Retry(async () =>
108+
{
109+
using (var client = await GetRemoteZipClient(new Uri($"https://{functionApp.ScmUri}")))
110+
using (var request = new HttpRequestMessage(HttpMethod.Put, new Uri("api/zip/site/wwwroot", UriKind.Relative)))
111+
{
112+
request.Headers.IfMatch.Add(EntityTagHeaderValue.Any);
80113

81-
ColoredConsole.WriteLine("Creating archive for current directory...");
114+
ColoredConsole.WriteLine("Creating archive for current directory...");
82115

83-
request.Content = CreateZip(functionAppRoot);
116+
request.Content = CreateZip(functionAppRoot, ignoreParser);
84117

85-
ColoredConsole.WriteLine("Uploading archive...");
86-
var response = await client.SendAsync(request);
87-
if (!response.IsSuccessStatusCode)
88-
{
89-
throw new CliException($"Error uploading archive ({response.StatusCode}).");
90-
}
118+
ColoredConsole.WriteLine("Uploading archive...");
119+
var response = await client.SendAsync(request);
120+
if (!response.IsSuccessStatusCode)
121+
{
122+
throw new CliException($"Error uploading archive ({response.StatusCode}).");
123+
}
91124

92-
response = await client.PostAsync("api/functions/synctriggers", content: null);
93-
if (!response.IsSuccessStatusCode)
94-
{
95-
throw new CliException($"Error calling sync triggers ({response.StatusCode}).");
96-
}
125+
response = await client.PostAsync("api/functions/synctriggers", content: null);
126+
if (!response.IsSuccessStatusCode)
127+
{
128+
throw new CliException($"Error calling sync triggers ({response.StatusCode}).");
129+
}
97130

98-
if (PublishLocalSettings)
131+
if (PublishLocalSettings)
132+
{
133+
var isSuccessful = await PublishAppSettings(functionApp);
134+
if (!isSuccessful)
99135
{
100-
var isSuccessful = await PublishAppSettings(functionApp);
101-
if (!isSuccessful)
102-
{
103-
return;
104-
}
136+
return;
105137
}
106-
107-
ColoredConsole.WriteLine("Upload completed successfully.");
108138
}
109-
}, 2);
139+
140+
ColoredConsole.WriteLine("Upload completed successfully.");
141+
}
142+
}, 2);
143+
}
144+
145+
private static IEnumerable<string> GetFiles(string path)
146+
{
147+
return FileSystemHelpers.GetFiles(path, new[] { ".git", ".vscode" }, new[] { ".funcignore", ".gitignore", "appsettings.json", "local.settings.json", "project.lock.json" });
148+
}
149+
150+
private async Task InternalPublishLocalSettingsOnly()
151+
{
152+
ColoredConsole.WriteLine("Getting site publishing info...");
153+
var functionApp = await _armManager.GetFunctionAppAsync(FunctionAppName);
154+
var isSuccessful = await PublishAppSettings(functionApp);
155+
if (!isSuccessful)
156+
{
157+
return;
158+
}
159+
}
160+
161+
private static void InternalListIgnoredFiles(GitIgnoreParser ignoreParser)
162+
{
163+
if (ignoreParser == null)
164+
{
165+
ColoredConsole.Error.WriteLine("No .funcignore file");
166+
return;
167+
}
168+
169+
foreach (var file in GetFiles(Environment.CurrentDirectory).Select(f => f.Replace(Environment.CurrentDirectory, "").Trim(Path.DirectorySeparatorChar).Replace("\\", "/")))
170+
{
171+
if (ignoreParser.Denies(file))
172+
{
173+
ColoredConsole.WriteLine(file);
174+
}
175+
}
176+
}
177+
178+
private static void InternalListIncludedFiles(GitIgnoreParser ignoreParser)
179+
{
180+
if (ignoreParser == null)
181+
{
182+
ColoredConsole.Error.WriteLine("No .funcignore file");
183+
return;
184+
}
185+
186+
foreach (var file in GetFiles(Environment.CurrentDirectory).Select(f => f.Replace(Environment.CurrentDirectory, "").Trim(Path.DirectorySeparatorChar).Replace("\\", "/")))
187+
{
188+
if (ignoreParser.Accepts(file))
189+
{
190+
ColoredConsole.WriteLine(file);
191+
}
110192
}
111193
}
112194

@@ -173,14 +255,17 @@ private IDictionary<string, string> MergeAppSettings(IDictionary<string, string>
173255
return result;
174256
}
175257

176-
private static StreamContent CreateZip(string path)
258+
private static StreamContent CreateZip(string path, GitIgnoreParser ignoreParser)
177259
{
178260
var memoryStream = new MemoryStream();
179261
using (var zip = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true))
180262
{
181-
foreach (var fileName in FileSystemHelpers.GetFiles(path, new[] { ".git", ".vscode" }, new[] { ".gitignore", "appsettings.json", "local.settings.json", "project.lock.json" }))
263+
foreach (var fileName in GetFiles(path))
182264
{
183-
zip.AddFile(fileName, fileName, path);
265+
if (ignoreParser?.Accepts(fileName.Replace(path, "").Trim(Path.DirectorySeparatorChar).Replace("\\", "/")) ?? true)
266+
{
267+
zip.AddFile(fileName, fileName, path);
268+
}
184269
}
185270
}
186271
memoryStream.Seek(0, SeekOrigin.Begin);

src/Azure.Functions.Cli/Azure.Functions.Cli.csproj

+1
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,7 @@
723723
<Compile Include="Common\ExitCodes.cs" />
724724
<Compile Include="Common\FileSystemHelpers.cs" />
725725
<Compile Include="Common\FunctionNotFoundException.cs" />
726+
<Compile Include="Common\GitIgnoreParser.cs" />
726727
<Compile Include="Common\HostStartSettings.cs" />
727728
<Compile Include="Common\HttpResult.cs" />
728729
<Compile Include="Common\OutputTheme.cs" />

src/Azure.Functions.Cli/Common/Constants.cs

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ internal static class Constants
1111
public const string DefaultSqlProviderName = "System.Data.SqlClient";
1212
public const string WebsiteHostname = "WEBSITE_HOSTNAME";
1313
public const string ProxiesFileName = "proxies.json";
14+
public const string FuncIgnoreFile = ".funcignore";
1415

1516
public static class Errors
1617
{

src/Azure.Functions.Cli/Common/FileSystemHelpers.cs

+5
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ internal static IEnumerable<string> GetFiles(string directoryPath, IEnumerable<s
118118
}
119119
}
120120

121+
internal static bool FileExists(object funcIgnoreFile)
122+
{
123+
throw new NotImplementedException();
124+
}
125+
121126
internal static IEnumerable<string> GetDirectories(string scriptPath)
122127
{
123128
return Instance.Directory.GetDirectories(scriptPath);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
using System.Text.RegularExpressions;
4+
5+
namespace Azure.Functions.Cli.Common
6+
{
7+
/// <summary>
8+
/// This is a C# reimplementation of https://github.com/codemix/gitignore-parser
9+
/// License for gitignore-parser:
10+
/// # Copyright 2014 codemix ltd.
11+
/// Licensed under the Apache License, Version 2.0 (the "License");
12+
/// you may not use this file except in compliance with the License.
13+
/// You may obtain a copy of the License at
14+
///
15+
/// [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0)
16+
///
17+
/// Unless required by applicable law or agreed to in writing, software
18+
/// distributed under the License is distributed on an "AS IS" BASIS,
19+
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20+
/// See the License for the specific language governing permissions and
21+
/// limitations under the License.
22+
/// </summary>
23+
internal class GitIgnoreParser
24+
{
25+
private readonly Regex[] _negative;
26+
private readonly Regex[] _positive;
27+
28+
public GitIgnoreParser(string gitIgnoreContent)
29+
{
30+
var parsed = gitIgnoreContent
31+
.Split('\n')
32+
.Select(l => l.Trim())
33+
.Where(l => !l.StartsWith("#"))
34+
.Where(l => !string.IsNullOrEmpty(l))
35+
.Aggregate(new List<List<string>>() { new List<string>(), new List<string>() }, (lists, line) =>
36+
{
37+
var isNegative = line.StartsWith("!");
38+
if (isNegative)
39+
{
40+
line = line.Substring(1);
41+
}
42+
if (line.StartsWith("/"))
43+
{
44+
line = line.Substring(1);
45+
}
46+
if (isNegative)
47+
{
48+
lists[1].Add(line);
49+
}
50+
else
51+
{
52+
lists[0].Add(line);
53+
}
54+
return lists;
55+
})
56+
.Select(l =>
57+
{
58+
return l
59+
.OrderBy(i => i)
60+
.Select(i => new[] { PrepareRegexPattern(i), PreparePartialRegex(i) })
61+
.Aggregate(new List<List<string>>() { new List<string>(), new List<string>() }, (list, prepared) =>
62+
{
63+
list[0].Add(prepared[0]);
64+
list[1].Add(prepared[1]);
65+
return list;
66+
});
67+
})
68+
.Select(item => new[]
69+
{
70+
item[0].Count > 0 ? new Regex("^((" + string.Join(")|(", item[0]) + "))", RegexOptions.ECMAScript) : new Regex("$^", RegexOptions.ECMAScript),
71+
item[1].Count > 0 ? new Regex("^((" + string.Join(")|(", item[1]) + "))", RegexOptions.ECMAScript) : new Regex("$^", RegexOptions.ECMAScript)
72+
})
73+
.ToArray();
74+
_positive = parsed[0];
75+
_negative = parsed[1];
76+
}
77+
78+
public bool Accepts(string input)
79+
{
80+
if (input == "/")
81+
{
82+
input = input.Substring(1);
83+
}
84+
return _negative[0].IsMatch(input) || !_positive[0].IsMatch(input);
85+
}
86+
87+
public bool Denies(string input)
88+
{
89+
if (input == "/")
90+
{
91+
input = input.Substring(1);
92+
}
93+
return !(_negative[0].IsMatch(input) || !_positive[0].IsMatch(input));
94+
}
95+
96+
private string PrepareRegexPattern(string pattern)
97+
{
98+
return Regex.Replace(pattern, @"[\-\[\]\/\{\}\(\)\+\?\.\\\^\$\|]", "\\$&", RegexOptions.ECMAScript)
99+
.Replace("**", "(.+)")
100+
.Replace("*", "([^\\/]+)");
101+
}
102+
103+
private string PreparePartialRegex(string pattern)
104+
{
105+
return pattern
106+
.Split('/')
107+
.Select((item, index) =>
108+
{
109+
if (index == 0)
110+
{
111+
return "([\\/]?(" + PrepareRegexPattern(item) + "\\b|$))";
112+
}
113+
else
114+
{
115+
return "(" + PrepareRegexPattern(item) + "\\b)";
116+
}
117+
})
118+
.Aggregate((a, b) => a + b);
119+
}
120+
}
121+
}

test/Azure.Functions.Cli.Tests/Azure.Functions.Cli.Tests.csproj

+1
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,7 @@
541541
<Compile Include="ExtensionsTests\ProcessExtensionsTests.cs" />
542542
<Compile Include="ExtensionsTests\TaskExtensionsTests.cs" />
543543
<Compile Include="ExtensionsTests\UriExtensionsTests.cs" />
544+
<Compile Include="GitIgnoreParserTests.cs" />
544545
<Compile Include="Properties\AssemblyInfo.cs" />
545546
</ItemGroup>
546547
<ItemGroup>

0 commit comments

Comments
 (0)