diff --git a/README.md b/README.md
index d49fb1e4..45d6bbf5 100644
--- a/README.md
+++ b/README.md
@@ -47,7 +47,7 @@
## Translation Status
-[![en_US](https://img.shields.io/badge/en__US-100%25-brightgreen)](TRANSLATION.md) [![de__DE](https://img.shields.io/badge/de__DE-100.00%25-brightgreen)](TRANSLATION.md) [![es__ES](https://img.shields.io/badge/es__ES-99.14%25-yellow)](TRANSLATION.md) [![fr__FR](https://img.shields.io/badge/fr__FR-98.42%25-yellow)](TRANSLATION.md) [![pt__BR](https://img.shields.io/badge/pt__BR-99.14%25-yellow)](TRANSLATION.md) [![ru__RU](https://img.shields.io/badge/ru__RU-100.00%25-brightgreen)](TRANSLATION.md) [![zh__CN](https://img.shields.io/badge/zh__CN-100.00%25-brightgreen)](TRANSLATION.md) [![zh__TW](https://img.shields.io/badge/zh__TW-100.00%25-brightgreen)](TRANSLATION.md)
+[![en_US](https://img.shields.io/badge/en__US-100%25-brightgreen)](TRANSLATION.md) [![de__DE](https://img.shields.io/badge/de__DE-99.86%25-yellow)](TRANSLATION.md) [![es__ES](https://img.shields.io/badge/es__ES-98.01%25-yellow)](TRANSLATION.md) [![fr__FR](https://img.shields.io/badge/fr__FR-97.44%25-yellow)](TRANSLATION.md) [![pt__BR](https://img.shields.io/badge/pt__BR-99.29%25-yellow)](TRANSLATION.md) [![ru__RU](https://img.shields.io/badge/ru__RU-100.00%25-brightgreen)](TRANSLATION.md) [![zh__CN](https://img.shields.io/badge/zh__CN-100.00%25-brightgreen)](TRANSLATION.md) [![zh__TW](https://img.shields.io/badge/zh__TW-100.00%25-brightgreen)](TRANSLATION.md)
## How to Use
@@ -101,6 +101,7 @@ For **Linux** users:
* `xdg-open` must be installed to support open native file manager.
* Make sure [git-credential-manager](https://github.com/git-ecosystem/git-credential-manager/releases) is installed on your linux.
* Maybe you need to set environment variable `AVALONIA_SCREEN_SCALE_FACTORS`. See https://github.com/AvaloniaUI/Avalonia/wiki/Configuring-X11-per-monitor-DPI.
+* If you can NOT type accented characters, such as `ê`, `ó`, try to set the environment variable `AVALONIA_IM_MODULE` to `none`.
## OpenAI
diff --git a/TRANSLATION.md b/TRANSLATION.md
index 5967d21e..03b26e1b 100644
--- a/TRANSLATION.md
+++ b/TRANSLATION.md
@@ -1,29 +1,37 @@
-### de_DE.axaml: 100.00%
+### de_DE.axaml: 99.86%
Missing Keys
-
+- Text.Repository.FilterCommits
-### es_ES.axaml: 99.14%
+### es_ES.axaml: 98.01%
Missing Keys
+- Text.CommitDetail.Info.Children
+- Text.Fetch.Force
- Text.Preference.Appearance.FontSize
- Text.Preference.Appearance.FontSize.Default
- Text.Preference.Appearance.FontSize.Editor
+- Text.Preference.General.ShowChildren
+- Text.Repository.FilterCommits
- Text.Repository.FilterCommits.Default
- Text.Repository.FilterCommits.Exclude
- Text.Repository.FilterCommits.Include
+- Text.Repository.HistoriesOrder
+- Text.Repository.HistoriesOrder.ByDate
+- Text.Repository.HistoriesOrder.Topo
+- Text.SHALinkCM.NavigateTo
-### fr_FR.axaml: 98.42%
+### fr_FR.axaml: 97.44%
@@ -32,29 +40,35 @@
- Text.CherryPick.AppendSourceToMessage
- Text.CherryPick.Mainline.Tips
- Text.CommitCM.CherryPickMultiple
+- Text.Fetch.Force
- Text.Preference.Appearance.FontSize
- Text.Preference.Appearance.FontSize.Default
- Text.Preference.Appearance.FontSize.Editor
+- Text.Preference.General.ShowChildren
- Text.Repository.CustomActions
+- Text.Repository.FilterCommits
- Text.Repository.FilterCommits.Default
- Text.Repository.FilterCommits.Exclude
- Text.Repository.FilterCommits.Include
+- Text.Repository.HistoriesOrder
+- Text.Repository.HistoriesOrder.ByDate
+- Text.Repository.HistoriesOrder.Topo
- Text.ScanRepositories
+- Text.SHALinkCM.NavigateTo
-### pt_BR.axaml: 99.14%
+### pt_BR.axaml: 99.29%
Missing Keys
-- Text.Preference.Appearance.FontSize
-- Text.Preference.Appearance.FontSize.Default
-- Text.Preference.Appearance.FontSize.Editor
-- Text.Repository.FilterCommits.Default
-- Text.Repository.FilterCommits.Exclude
-- Text.Repository.FilterCommits.Include
+- Text.CommitDetail.Info.Children
+- Text.Fetch.Force
+- Text.Preference.General.ShowChildren
+- Text.Repository.FilterCommits
+- Text.SHALinkCM.NavigateTo
diff --git a/VERSION b/VERSION
index fb6559a3..081fd762 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-8.39
\ No newline at end of file
+8.40
\ No newline at end of file
diff --git a/src/Commands/Fetch.cs b/src/Commands/Fetch.cs
index 834cd7fc..1c3e78cb 100644
--- a/src/Commands/Fetch.cs
+++ b/src/Commands/Fetch.cs
@@ -4,7 +4,7 @@ namespace SourceGit.Commands
{
public class Fetch : Command
{
- public Fetch(string repo, string remote, bool noTags, bool prune, Action outputHandler)
+ public Fetch(string repo, string remote, bool noTags, bool prune, bool force, Action outputHandler)
{
_outputHandler = outputHandler;
WorkingDirectory = repo;
@@ -18,6 +18,9 @@ namespace SourceGit.Commands
else
Args += "--tags ";
+ if (force)
+ Args += "--force ";
+
if (prune)
Args += "--prune ";
diff --git a/src/Commands/FormatPatch.cs b/src/Commands/FormatPatch.cs
index 2c7359c0..b3ec2e4a 100644
--- a/src/Commands/FormatPatch.cs
+++ b/src/Commands/FormatPatch.cs
@@ -6,7 +6,7 @@
{
WorkingDirectory = repo;
Context = repo;
- Args = $"format-patch {commit} -1 -o \"{saveTo}\"";
+ Args = $"format-patch {commit} -1 --output=\"{saveTo}\"";
}
}
}
diff --git a/src/Commands/QueryCommitChildren.cs b/src/Commands/QueryCommitChildren.cs
new file mode 100644
index 00000000..293de912
--- /dev/null
+++ b/src/Commands/QueryCommitChildren.cs
@@ -0,0 +1,32 @@
+using System.Collections.Generic;
+
+namespace SourceGit.Commands
+{
+ public class QueryCommitChildren : Command
+ {
+ public QueryCommitChildren(string repo, string commit, int max, string filters)
+ {
+ WorkingDirectory = repo;
+ Context = repo;
+ _commit = commit;
+ if (string.IsNullOrEmpty(filters))
+ filters = "--branches --remotes --tags";
+ Args = $"rev-list -{max} --parents {filters} ^{commit}";
+ }
+
+ public IEnumerable Result()
+ {
+ Exec();
+ return _lines;
+ }
+
+ protected override void OnReadline(string line)
+ {
+ if (line.Contains(_commit))
+ _lines.Add(line.Substring(0, 40));
+ }
+
+ private string _commit;
+ private List _lines = new List();
+ }
+}
diff --git a/src/Commands/QueryCommits.cs b/src/Commands/QueryCommits.cs
index 5875301e..80497a90 100644
--- a/src/Commands/QueryCommits.cs
+++ b/src/Commands/QueryCommits.cs
@@ -6,11 +6,13 @@ namespace SourceGit.Commands
{
public class QueryCommits : Command
{
- public QueryCommits(string repo, string limits, bool needFindHead = true)
+ public QueryCommits(string repo, bool useTopoOrder, string limits, bool needFindHead = true)
{
+ var order = useTopoOrder ? "--topo-order" : "--date-order";
+
WorkingDirectory = repo;
Context = repo;
- Args = "log --date-order --no-show-signature --decorate=full --pretty=format:%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%s " + limits;
+ Args = $"log {order} --no-show-signature --decorate=full --pretty=format:%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%s {limits}";
_findFirstMerged = needFindHead;
}
diff --git a/src/Models/CommitTemplate.cs b/src/Models/CommitTemplate.cs
index b34fa5a5..56e1992c 100644
--- a/src/Models/CommitTemplate.cs
+++ b/src/Models/CommitTemplate.cs
@@ -1,7 +1,4 @@
-using System;
-using System.Collections.Generic;
-using System.Text;
-using System.Text.RegularExpressions;
+using System.Collections.Generic;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -9,9 +6,6 @@ namespace SourceGit.Models
{
public partial class CommitTemplate : ObservableObject
{
- [GeneratedRegex(@"\$\{files(\:\d+)?\}")]
- private static partial Regex REG_COMMIT_TEMPLATE_FILES();
-
public string Name
{
get => _name;
@@ -26,55 +20,8 @@ namespace SourceGit.Models
public string Apply(Branch branch, List changes)
{
- var content = _content
- .Replace("${files_num}", $"{changes.Count}")
- .Replace("${branch_name}", branch.Name);
-
- var matches = REG_COMMIT_TEMPLATE_FILES().Matches(content);
- if (matches.Count == 0)
- return content;
-
- var builder = new StringBuilder();
- var last = 0;
- for (int i = 0; i < matches.Count; i++)
- {
- var match = matches[i];
- if (!match.Success)
- continue;
-
- var start = match.Index;
- if (start != last)
- builder.Append(content.Substring(last, start - last));
-
- var countStr = match.Groups[1].Value;
- var paths = new List();
- var more = string.Empty;
- if (countStr is { Length: <= 1 })
- {
- foreach (var c in changes)
- paths.Add(c.Path);
- }
- else
- {
- var count = Math.Min(int.Parse(countStr.Substring(1)), changes.Count);
- for (int j = 0; j < count; j++)
- paths.Add(changes[j].Path);
-
- if (count < changes.Count)
- more = $" and {changes.Count - count} other files";
- }
-
- builder.Append(string.Join(", ", paths));
- if (!string.IsNullOrEmpty(more))
- builder.Append(more);
-
- last = start + match.Length;
- }
-
- if (last != content.Length - 1)
- builder.Append(content.Substring(last));
-
- return builder.ToString();
+ var te = new TemplateEngine();
+ return te.Eval(_content, branch, changes);
}
private string _name = string.Empty;
diff --git a/src/Models/OpenAI.cs b/src/Models/OpenAI.cs
index c5ca7449..df67ff66 100644
--- a/src/Models/OpenAI.cs
+++ b/src/Models/OpenAI.cs
@@ -150,7 +150,7 @@ namespace SourceGit.Models
public OpenAIChatResponse Chat(string prompt, string question, CancellationToken cancellation)
{
var chat = new OpenAIChatRequest() { Model = Model };
- chat.AddMessage("system", prompt);
+ chat.AddMessage("user", prompt);
chat.AddMessage("user", question);
var client = new HttpClient() { Timeout = TimeSpan.FromSeconds(60) };
@@ -169,12 +169,15 @@ namespace SourceGit.Models
task.Wait(cancellation);
var rsp = task.Result;
- if (!rsp.IsSuccessStatusCode)
- throw new Exception($"AI service returns error code {rsp.StatusCode}");
-
var reader = rsp.Content.ReadAsStringAsync(cancellation);
reader.Wait(cancellation);
+ var body = reader.Result;
+ if (!rsp.IsSuccessStatusCode)
+ {
+ throw new Exception($"AI service returns error code {rsp.StatusCode}. Body: {body ?? string.Empty}");
+ }
+
return JsonSerializer.Deserialize(reader.Result, JsonCodeGen.Default.OpenAIChatResponse);
}
catch
diff --git a/src/Models/Remote.cs b/src/Models/Remote.cs
index dcf30ddc..2b88c3be 100644
--- a/src/Models/Remote.cs
+++ b/src/Models/Remote.cs
@@ -1,11 +1,12 @@
using System;
+using System.IO;
using System.Text.RegularExpressions;
namespace SourceGit.Models
{
public partial class Remote
{
- [GeneratedRegex(@"^http[s]?://([\w\-]+@)?[\w\.\-]+(\:[0-9]+)?/[\w\-/~%]+/[\w\-\.%]+(\.git)?$")]
+ [GeneratedRegex(@"^https?://([-a-zA-Z0-9:%._\+~#=]+@)?[-a-zA-Z0-9:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}(:[0-9]{1,5})?\b(/[-a-zA-Z0-9()@:%_\+.~#?&=]*)*(\.git)?$")]
private static partial Regex REG_HTTPS();
[GeneratedRegex(@"^[\w\-]+@[\w\.\-]+(\:[0-9]+)?:[\w\-/~%]+/[\w\-\.%]+(\.git)?$")]
private static partial Regex REG_SSH1();
@@ -49,7 +50,7 @@ namespace SourceGit.Models
return true;
}
- return false;
+ return url.EndsWith(".git", StringComparison.Ordinal) && Directory.Exists(url);
}
public bool TryGetVisitURL(out string url)
diff --git a/src/Models/RepositorySettings.cs b/src/Models/RepositorySettings.cs
index f6796198..5b3aa331 100644
--- a/src/Models/RepositorySettings.cs
+++ b/src/Models/RepositorySettings.cs
@@ -320,6 +320,8 @@ namespace SourceGit.Models
{
builder.Append("--exclude=");
builder.Append(b);
+ builder.Append(" --decorate-refs-exclude=refs/heads/");
+ builder.Append(b);
builder.Append(' ');
}
}
@@ -332,6 +334,8 @@ namespace SourceGit.Models
{
builder.Append("--exclude=");
builder.Append(r);
+ builder.Append(" --decorate-refs-exclude=refs/remotes/");
+ builder.Append(r);
builder.Append(' ');
}
}
@@ -344,6 +348,8 @@ namespace SourceGit.Models
{
builder.Append("--exclude=");
builder.Append(t);
+ builder.Append(" --decorate-refs-exclude=refs/tags/");
+ builder.Append(t);
builder.Append(' ');
}
}
diff --git a/src/Models/TemplateEngine.cs b/src/Models/TemplateEngine.cs
new file mode 100644
index 00000000..6b5f525d
--- /dev/null
+++ b/src/Models/TemplateEngine.cs
@@ -0,0 +1,410 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace SourceGit.Models
+{
+ public class TemplateEngine
+ {
+ private class Context(Branch branch, IReadOnlyList changes)
+ {
+ public Branch branch = branch;
+ public IReadOnlyList changes = changes;
+ }
+
+ private class Text(string text)
+ {
+ public string text = text;
+ }
+
+ private class Variable(string name)
+ {
+ public string name = name;
+ }
+
+ private class SlicedVariable(string name, int count)
+ {
+ public string name = name;
+ public int count = count;
+ }
+
+ private class RegexVariable(string name, Regex regex, string replacement)
+ {
+ public string name = name;
+ public Regex regex = regex;
+ public string replacement = replacement;
+ }
+
+ private const char ESCAPE = '\\';
+ private const char VARIABLE_ANCHOR = '$';
+ private const char VARIABLE_START = '{';
+ private const char VARIABLE_END = '}';
+ private const char VARIABLE_SLICE = ':';
+ private const char VARIABLE_REGEX = '/';
+ private const char NEWLINE = '\n';
+ private const RegexOptions REGEX_OPTIONS = RegexOptions.Singleline | RegexOptions.IgnoreCase;
+
+ public string Eval(string text, Branch branch, IReadOnlyList changes)
+ {
+ Reset();
+
+ _chars = text.ToCharArray();
+ Parse();
+
+ var context = new Context(branch, changes);
+ var sb = new StringBuilder();
+ sb.EnsureCapacity(text.Length);
+ foreach (var token in _tokens)
+ {
+ switch (token)
+ {
+ case Text text_token:
+ sb.Append(text_token.text);
+ break;
+ case Variable var_token:
+ sb.Append(EvalVariable(context, var_token));
+ break;
+ case SlicedVariable sliced_var:
+ sb.Append(EvalVariable(context, sliced_var));
+ break;
+ case RegexVariable regex_var:
+ sb.Append(EvalVariable(context, regex_var));
+ break;
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ private void Reset()
+ {
+ _pos = 0;
+ _chars = [];
+ _tokens.Clear();
+ }
+
+ private char? Next()
+ {
+ var c = Peek();
+ if (c is not null)
+ {
+ _pos++;
+ }
+ return c;
+ }
+
+ private char? Peek()
+ {
+ return (_pos >= _chars.Length) ? null : _chars[_pos];
+ }
+
+ private int? Integer()
+ {
+ var start = _pos;
+ while (Peek() is char c && c >= '0' && c <= '9')
+ {
+ _pos++;
+ }
+ if (start >= _pos)
+ return null;
+
+ var chars = new ReadOnlySpan(_chars, start, _pos - start);
+ return int.Parse(chars);
+ }
+
+ private void Parse()
+ {
+ // text token start
+ var tok = _pos;
+ bool esc = false;
+ while (Next() is char c)
+ {
+ if (esc)
+ {
+ esc = false;
+ continue;
+ }
+ switch (c)
+ {
+ case ESCAPE:
+ // allow to escape only \ and $
+ if (Peek() is char nc && (nc == ESCAPE || nc == VARIABLE_ANCHOR))
+ {
+ esc = true;
+ FlushText(tok, _pos - 1);
+ tok = _pos;
+ }
+ break;
+ case VARIABLE_ANCHOR:
+ // backup the position
+ var bak = _pos;
+ var variable = TryParseVariable();
+ if (variable is null)
+ {
+ // no variable found, rollback
+ _pos = bak;
+ }
+ else
+ {
+ // variable found, flush a text token
+ FlushText(tok, bak - 1);
+ _tokens.Add(variable);
+ tok = _pos;
+ }
+ break;
+ }
+ }
+ // flush text token
+ FlushText(tok, _pos);
+ }
+
+ private void FlushText(int start, int end)
+ {
+ int len = end - start;
+ if (len <= 0)
+ return;
+ var text = new string(_chars, start, len);
+ _tokens.Add(new Text(text));
+ }
+
+ private object TryParseVariable()
+ {
+ if (Next() != VARIABLE_START)
+ return null;
+ int name_start = _pos;
+ while (Next() is char c)
+ {
+ // name character, continue advancing
+ if (IsNameChar(c))
+ continue;
+
+ var name_end = _pos - 1;
+ // not a name character but name is empty, cancel
+ if (name_start >= name_end)
+ return null;
+ var name = new string(_chars, name_start, name_end - name_start);
+
+ return c switch
+ {
+ // variable
+ VARIABLE_END => new Variable(name),
+ // sliced variable
+ VARIABLE_SLICE => TryParseSlicedVariable(name),
+ // regex variable
+ VARIABLE_REGEX => TryParseRegexVariable(name),
+ _ => null,
+ };
+ }
+
+ return null;
+ }
+
+ private object TryParseSlicedVariable(string name)
+ {
+ int? n = Integer();
+ if (n is null)
+ return null;
+ if (Next() != VARIABLE_END)
+ return null;
+
+ return new SlicedVariable(name, (int)n);
+ }
+
+ private object TryParseRegexVariable(string name)
+ {
+ var regex = ParseRegex();
+ if (regex == null)
+ return null;
+ var replacement = ParseReplacement();
+ if (replacement == null)
+ return null;
+
+ return new RegexVariable(name, regex, replacement);
+ }
+
+ private Regex ParseRegex()
+ {
+ var sb = new StringBuilder();
+ var tok = _pos;
+ var esc = false;
+ while (Next() is char c)
+ {
+ if (esc)
+ {
+ esc = false;
+ continue;
+ }
+ switch (c)
+ {
+ case ESCAPE:
+ // allow to escape only / as \ and { used frequently in regexes
+ if (Peek() == VARIABLE_REGEX)
+ {
+ esc = true;
+ sb.Append(_chars, tok, _pos - 1 - tok);
+ tok = _pos;
+ }
+ break;
+ case VARIABLE_REGEX:
+ // goto is fine
+ goto Loop_exit;
+ case NEWLINE:
+ // no newlines allowed
+ return null;
+ }
+ }
+ Loop_exit:
+ sb.Append(_chars, tok, _pos - 1 - tok);
+
+ try
+ {
+ var pattern = sb.ToString();
+ if (pattern.Length == 0)
+ return null;
+ var regex = new Regex(pattern, REGEX_OPTIONS);
+
+ return regex;
+ }
+ catch (RegexParseException)
+ {
+ return null;
+ }
+ }
+
+ private string ParseReplacement()
+ {
+ var sb = new StringBuilder();
+ var tok = _pos;
+ var esc = false;
+ while (Next() is char c)
+ {
+ if (esc)
+ {
+ esc = false;
+ continue;
+ }
+ switch (c)
+ {
+ case ESCAPE:
+ // allow to escape only }
+ if (Peek() == VARIABLE_END)
+ {
+ esc = true;
+ sb.Append(_chars, tok, _pos - 1 - tok);
+ tok = _pos;
+ }
+ break;
+ case VARIABLE_END:
+ // goto is fine
+ goto Loop_exit;
+ case NEWLINE:
+ // no newlines allowed
+ return null;
+ }
+ }
+ Loop_exit:
+ sb.Append(_chars, tok, _pos - 1 - tok);
+
+ var replacement = sb.ToString();
+
+ return replacement;
+ }
+
+ private static bool IsNameChar(char c)
+ {
+ return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9');
+ }
+
+ // (?) notice or log if variable is not found
+ private static string EvalVariable(Context context, string name)
+ {
+ if (!s_variables.TryGetValue(name, out var getter))
+ {
+ return string.Empty;
+ }
+ return getter(context);
+ }
+
+ private static string EvalVariable(Context context, Variable variable)
+ {
+ return EvalVariable(context, variable.name);
+ }
+
+ private static string EvalVariable(Context context, SlicedVariable variable)
+ {
+ if (!s_slicedVariables.TryGetValue(variable.name, out var getter))
+ {
+ return string.Empty;
+ }
+ return getter(context, variable.count);
+ }
+
+ private static string EvalVariable(Context context, RegexVariable variable)
+ {
+ var str = EvalVariable(context, variable.name);
+ if (string.IsNullOrEmpty(str))
+ return str;
+ return variable.regex.Replace(str, variable.replacement);
+ }
+
+ private int _pos = 0;
+ private char[] _chars = [];
+ private readonly List