Skip to content

Instantly share code, notes, and snippets.

@esperecyan
Last active March 14, 2021 09:38
Show Gist options
  • Save esperecyan/27ae8c1cc69ff1c44319703d4d7c6fa0 to your computer and use it in GitHub Desktop.
Save esperecyan/27ae8c1cc69ff1c44319703d4d7c6fa0 to your computer and use it in GitHub Desktop.
『VRChatCrossGit.cs』 Sync two local Unity projects using Git. Put this script into “Assets/Editor” folder. / Gitを利用してローカルの2つのUnityプロジェクトを同期します。使い方: https://gitlab.com/vrc-gitcommitter/wiki/-/wikis/%E3%80%90VRChat%E3%80%91%E7%B6%99%E7%B6%9A%E7%9A%84%E3%81%AA%E3%82%AF%E3%83%AD%E3%82%B9%E3%83%97%E3%83%A9%E3%83%83%E3%83%88%E3%83%95%E3%82%A9%E3%83%BC%…
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Diagnostics;
using System.ComponentModel;
using UnityEngine;
using UnityEditor;
namespace Esperecyan.Unity.VRChatCrossGit
{
/// <summary>
/// Sync two local Unity projects using Git.
/// Gitを利用してローカルの2つのUnityプロジェクトを同期します。
/// </summary>
/// <remarks>
/// Adds items into the menu “VRChat SDK ▸ Utilities”.
/// VRChat SDK ▸ Utilities メニュー内に項目を追加します。
///
/// 【Create ○○ project】
/// Create .gitignore
/// git init
/// git remote add ○○ "../□□□□[○○]"
/// CD "../□□□□[○○]"
/// git init
/// git config --local receive.denyCurrentBranch ignore
///
/// 【Force Push to ○○ project (Not Commit)】
/// git push --force ◇◇◇ "../□□□□[○○]"
/// CD "../□□□□[○○]"
/// git reset --hard
/// git checkout ◇◇◇
///
/// 【Commit and Force Push to ○○ project】
/// git add --all
/// git commit "--message=△△△△△△△△△△△"
/// git push --force ◇◇◇ "../□□□□[○○]"
/// CD "../□□□□[○○]"
/// git reset --hard
/// git checkout ◇◇◇
///
/// Licence: MIT License (MIT) <https://spdx.org/licenses/MIT.html>
/// Destination: <https://gist.github.com/esperecyan/27ae8c1cc69ff1c44319703d4d7c6fa0>
/// 更新履歴:
/// v2.0.0 2021-03-14
/// masterブランチではなくカレントブランチをpushするようにした
/// reset後にcheckoutするようにした
/// Clone先プロジェクト名の角括弧の前に、空白を前置詞内容にした (パスに空白が含まれているとLFSでエラーが起きるため)
/// v1.0.0 2019-12-01
/// 公開
/// </remarks>
internal class VRChatCrossGit
{
/// <summary>
/// 追加するメニュー項目の位置。
/// </summary>
public const int Priority = 1101;
internal static readonly string Name = "VRChatCrossGit.cs-2.0.0";
internal static readonly string RemoteNamePrefix = "VRChatCrossGit";
#if UNITY_STANDALONE
internal const string CurrentPlatform = "PC";
internal const string OtherPlatform = "Quest";
#else
internal const string CurrentPlatform = "Quest";
internal const string OtherPlatform = "PC";
#endif
private static readonly string Gitignore =
@"# This .gitignore file should be placed at the root of your Unity project directory
#
# Get latest from https://github.com/github/gitignore/blob/master/Unity.gitignore
#
/[Ll]ibrary/
/[Tt]emp/
/[Oo]bj/
/[Bb]uild/
/[Bb]uilds/
/[Ll]ogs/
/[Mm]emoryCaptures/
# Never ignore Asset meta data
!/[Aa]ssets/**/*.meta
# Uncomment this line if you wish to ignore the asset store tools plugin
# /[Aa]ssets/AssetStoreTools*
# Autogenerated Jetbrains Rider plugin
[Aa]ssets/Plugins/Editor/JetBrains*
# Visual Studio cache directory
.vs/
# Gradle cache directory
.gradle/
# Autogenerated VS/MD/Consulo solution and project files
ExportedObj/
.consulo/
*.csproj
*.unityproj
*.sln
*.suo
*.tmp
*.user
*.userprefs
*.pidb
*.booproj
*.svd
*.pdb
*.mdb
*.opendb
*.VC.db
# Unity3D generated meta files
*.pidb.meta
*.pdb.meta
*.mdb.meta
# Unity3D generated file on crash reports
sysinfo.txt
# Builds
*.apk
*.unitypackage
# Crashlytics generated file
crashlytics-build.properties
";
static VRChatCrossGit()
{
Gettext.SetLocalizedTexts(localizedTexts: new Dictionary<string, IDictionary<string, string>> {
{ "ja", new Dictionary<string, string> {
{ "OK", "OK" },
{ "Cancel", "キャンセル" },
{ "Completed.", "完了しました。" },
{ "Error occured. You can copy the description if click the error in Console window and press Ctrl+C.",
"エラーが発生しました。Consoleウィンドウのエラーをクリックして Ctrl+C を押すと詳細をコピーできます。" },
{ "Git is not installed yet.", "Gitがインストールされていません。" },
{ "Open download page", "ダウンロードページを開く" },
{ "The below folder already exists. The process will be stop.", "以下のフォルダがすでに存在しています。処理を終了します。" },
{ "Current Platform Project", "現在のプラットフォームのプロジェクト" },
{ "{0} Project (Current)", "{0} プロジェクト (現在)" },
{ "{0} Project (Clone Destination)", "{0} プロジェクト (Clone先)" },
{ "Not yet initialized. Execute “{0}” before.", "セットアップが行われていません。「{0}」を先に実行してください。" },
{ "If the commit message field is empty, current date and time will be used.", "コミットメッセージ入力欄が空の場合、現在の日時が使用されます。" },
}}
});
}
/// <summary>
/// gitコマンドを実行します。
/// </summary>
/// <param name="arguments"></param>
/// <param name="progress"><see cref="EditorUtility.DisplayProgressBar"/>の第3引数に指定する値。</param>
/// <param name="currentDirectory"></param>
/// <param name="successStatus">「0」に加えて指定値を成功扱いにします。</param>
/// <returns></returns>
internal static Result Git(
string arguments,
float? progress = null,
string currentDirectory = "",
int successStatus = 0
)
{
var command = "git " + arguments;
if (progress != null)
{
EditorUtility.DisplayProgressBar(VRChatCrossGit.Name, command, progress.Value);
}
Result result;
using (var process = new Process())
{
process.StartInfo = new ProcessStartInfo("git", arguments)
{
WorkingDirectory = currentDirectory,
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
try
{
process.Start();
}
catch (Win32Exception)
{
VRChatCrossGit.PromptInstallGit();
return new Result()
{
Command = command,
Status = int.MaxValue,
};
}
string stdout = process.StandardOutput.ReadToEnd();
string stderr = process.StandardError.ReadToEnd();
process.WaitForExit();
result = new Result()
{
Command = command,
Status = process.ExitCode,
Stdout = stdout,
Stderr = stderr,
};
}
if (result.Status == 0 || result.Status == successStatus)
{
return result;
}
VRChatCrossGit.OpenErrorDialog(result: result);
return result;
}
/// <summary>
/// 指定された文字列を <see cref="ProcessStartInfo.Arguments"> へスペース区切りで指定する引数の一つとしてエスケープします。
/// </summary>
/// <param name="argument"></param>
/// <returns></returns>
internal static string EscapeArgument(string argument)
{
return @"""" + Regex.Replace(input: argument, pattern: @"(\\*)""", replacement: @"\$1$&")
+ (argument.EndsWith(@"\") ? @"\" : "") + @"""";
}
/// <summary>
/// エラーダイアログを表示します。
/// </summary>
/// <param name="result"></param>
internal static void OpenErrorDialog(Result result)
{
var errorMessage = Gettext._(
"Error occured. You can copy the description if click the error in Console window and press Ctrl+C."
)
+ "\n\n[Command]\n" + result.Command
+ "\n\n[Exit Status]\n" + result.Status
+ "\n\n[stderr]\n" + result.Stderr;
UnityEngine.Debug.LogError(errorMessage);
EditorUtility.DisplayDialog(VRChatCrossGit.Name, errorMessage, Gettext._("OK"));
}
private static void PromptInstallGit()
{
EditorUtility.DisplayDialog(
VRChatCrossGit.Name,
Gettext._("Git is not installed yet."),
Gettext._("Open download page")
);
Application.OpenURL("https://git-scm.com/download/");
}
private static string GenerateOtherPlatformProjectFullPath(string projectFullPath)
{
var currentPlatformProjectNameSuffix = "[" + VRChatCrossGit.CurrentPlatform + "]";
var otherPlatformProjectNameSuffix = "[" + VRChatCrossGit.OtherPlatform + "]";
return projectFullPath.EndsWith(currentPlatformProjectNameSuffix)
? projectFullPath.Substring(0, projectFullPath.Length - currentPlatformProjectNameSuffix.Length)
+ otherPlatformProjectNameSuffix
: projectFullPath + otherPlatformProjectNameSuffix;
}
[MenuItem(
"VRChat SDK/Utilities/Create " + VRChatCrossGit.OtherPlatform + " project",
false,
VRChatCrossGit.Priority
)]
private static void Initialize()
{
string projectFullPath = Path.GetDirectoryName(Application.dataPath);
string otherPlatformProjectFullPath
= VRChatCrossGit.GenerateOtherPlatformProjectFullPath(projectFullPath: projectFullPath);
if (Directory.Exists(otherPlatformProjectFullPath))
{
EditorUtility.DisplayDialog(
VRChatCrossGit.Name,
Gettext._("The below folder already exists. The process will be stop.") + "\n\n"
+ otherPlatformProjectFullPath,
Gettext._("OK")
);
return;
}
if (!EditorUtility.DisplayDialog(
VRChatCrossGit.Name,
"[" + string.Format(Gettext._("{0} Project (Current)"), VRChatCrossGit.CurrentPlatform) + "]\n"
+ projectFullPath + "\n\n["
+ string.Format(Gettext._("{0} Project (Clone Destination)"), VRChatCrossGit.OtherPlatform) + "]\n"
+ otherPlatformProjectFullPath,
Gettext._("OK"),
Gettext._("Cancel")
))
{
return;
}
string gitignoreFullPath = Path.Combine(projectFullPath, ".gitignore");
if (!File.Exists(gitignoreFullPath))
{
File.WriteAllText(
path: gitignoreFullPath,
contents: VRChatCrossGit.Gitignore,
encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)
);
}
try
{
var end = 0;
var total = 5f;
if ((VRChatCrossGit.Git(arguments: "init", progress: end++ / total)).Status > 0)
{
return;
}
if (VRChatCrossGit.Git(arguments: "remote add "
+ VRChatCrossGit.EscapeArgument(VRChatCrossGit.RemoteNamePrefix + VRChatCrossGit.OtherPlatform)
+ " " + VRChatCrossGit.EscapeArgument(otherPlatformProjectFullPath), progress: end++ / total)
.Status > 0)
{
return;
}
Directory.CreateDirectory(otherPlatformProjectFullPath);
if (VRChatCrossGit
.Git(arguments: "init", progress: end++ / total, currentDirectory: otherPlatformProjectFullPath)
.Status > 0)
{
return;
}
if (VRChatCrossGit.Git(
arguments: "config --local receive.denyCurrentBranch ignore",
progress: end++ / total,
currentDirectory: otherPlatformProjectFullPath
).Status > 0)
{
return;
}
}
finally
{
EditorUtility.ClearProgressBar();
}
EditorUtility.DisplayDialog(VRChatCrossGit.Name, Gettext._("Completed."), Gettext._("OK"));
}
}
/// <summary>
/// コマンドを実行した結果。
/// </summary>
internal struct Result
{
internal string Command;
internal int Status;
internal string Stdout;
internal string Stderr;
}
internal class Sync : ScriptableWizard
{
[SerializeField]
private string commitMessage;
private string otherPlatformProjectFullPath;
private bool commit;
protected override bool DrawWizardGUI()
{
base.DrawWizardGUI();
EditorGUILayout.HelpBox(
Gettext._("If the commit message field is empty, current date and time will be used."),
MessageType.None
);
return true;
}
[MenuItem(
"VRChat SDK/Utilities/Force Push to " + VRChatCrossGit.OtherPlatform + " project (Not Commit)",
false,
VRChatCrossGit.Priority
)]
private static void Push()
{
Sync.CommitAndPush(commit: false);
}
[MenuItem(
"VRChat SDK/Utilities/Commit and Force Push to " + VRChatCrossGit.OtherPlatform + " project",
false,
VRChatCrossGit.Priority
)]
private static void CommitAndPush()
{
Sync.CommitAndPush(commit: true);
}
private static void CommitAndPush(bool commit)
{
if (!Directory.Exists(Path.Combine(Path.GetDirectoryName(Application.dataPath), ".git")))
{
EditorUtility.DisplayDialog(
VRChatCrossGit.Name,
string.Format(
Gettext._("Not yet initialized. Execute “{0}” before."),
"Create " + VRChatCrossGit.OtherPlatform + " project"
),
Gettext._("OK")
);
return;
}
Result result;
try
{
result = VRChatCrossGit.Git(
arguments: "remote get-url "
+ VRChatCrossGit.EscapeArgument(VRChatCrossGit.RemoteNamePrefix + VRChatCrossGit.OtherPlatform),
progress: 0
);
if (result.Status > 0)
{
return;
}
}
finally
{
EditorUtility.ClearProgressBar();
}
var wizard = ScriptableWizard.DisplayWizard<Sync>(
VRChatCrossGit.Name,
commit ? "Commit and Force Push" : "Force Push",
Gettext._("Cancel")
);
wizard.otherPlatformProjectFullPath = result.Stdout.Trim(); // 末尾の改行の除去
wizard.commit = commit;
}
private void OnWizardCreate()
{
try
{
var end = 0;
var total = this.commit ? 4f : 2f;
if (this.commit)
{
if (VRChatCrossGit.Git(arguments: "add --all", progress: end++ / total).Status > 0)
{
return;
}
if (string.IsNullOrEmpty(this.commitMessage))
{
this.commitMessage = DateTime.Now.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ssK");
}
if (VRChatCrossGit.Git(
arguments: "commit " + VRChatCrossGit.EscapeArgument("--message=" + this.commitMessage),
progress: end++ / total,
successStatus: 1 // 未コミットファイルが無くコミットが行われなかった場合でも続行
).Status > 1)
{
return;
}
}
var result = VRChatCrossGit.Git("symbolic-ref --short HEAD");
if (result.Status > 0)
{
return;
}
var branchName = result.Stdout.Trim();
if (VRChatCrossGit.Git(arguments: "push --force "
+ VRChatCrossGit.EscapeArgument(VRChatCrossGit.RemoteNamePrefix + VRChatCrossGit.OtherPlatform)
+ " " + VRChatCrossGit.EscapeArgument(branchName), progress: end++ / total)
.Status > 0)
{
return;
}
if (VRChatCrossGit.Git(
arguments: "reset --hard",
progress: end++ / total,
currentDirectory: this.otherPlatformProjectFullPath
).Status > 0)
{
return;
}
if (VRChatCrossGit.Git(
arguments: "checkout " + VRChatCrossGit.EscapeArgument(branchName),
progress: end++ / total,
currentDirectory: this.otherPlatformProjectFullPath
).Status > 0)
{
return;
}
}
finally
{
EditorUtility.ClearProgressBar();
}
EditorUtility.DisplayDialog(VRChatCrossGit.Name, Gettext._("Completed."), Gettext._("OK"));
}
private void OnWizardOtherButton()
{
this.Close();
}
}
/// <summary>
/// i18n。
/// </summary>
internal class Gettext
{
/// <summary>
/// 翻訳対象文字列 (msgid) の言語。IETF言語タグの「language」サブタグ。
/// </summary>
private static readonly string OriginalLocale = "en";
/// <summary>
/// クライアントの言語の翻訳リソースが存在しないとき、どの言語に翻訳するか。IETF言語タグの「language」サブタグ。
/// </summary>
private static readonly string DefaultLocale = "en";
/// <summary>
/// クライアントの言語。<see cref="Gettext.SetLocale"/>から変更されます。
/// </summary>
private static string langtag = "en";
/// <summary>
/// クライアントの言語のlanguage部分。<see cref="Gettext.SetLocale"/>から変更されます。
/// </summary>
private static string language = "en";
/// <summary>
/// 翻訳リソース。<see cref="Gettext.SetLocalizedTexts"/>から変更されます。
/// </summary>
private static IDictionary<string, IDictionary<string, string>> multilingualLocalizedTexts
= new Dictionary<string, IDictionary<string, string>> { };
static Gettext()
{
Gettext.SetLocale(
clientLang: Gettext.ConvertToLangtagFromSystemLanguage(systemLanguage: Application.systemLanguage)
);
}
/// <summary>
/// 翻訳リソースを追加します。
/// </summary>
/// <param name="localizedTexts"></param>
internal static void SetLocalizedTexts(IDictionary<string, IDictionary<string, string>> localizedTexts)
{
Gettext.multilingualLocalizedTexts = localizedTexts;
}
/// <summary>
/// クライアントの言語を設定します。
/// </summary>
/// <param name="clientLang">IETF言語タグ (「language」と「language-REGION」にのみ対応)。</param>
internal static void SetLocale(string clientLang)
{
string[] splitedClientLang = clientLang.Split(separator: '-');
Gettext.language = splitedClientLang[0].ToLower();
Gettext.langtag = string.Join(separator: "-", value: splitedClientLang, startIndex: 0, count: Math.Min(2, splitedClientLang.Length));
if (Gettext.language == "ja")
{
// ja-JPをjaと同一視
Gettext.langtag = Gettext.language;
}
}
/// <summary>
/// テキストをクライアントの言語に変換します。
/// </summary>
/// <param name="message">翻訳前。</param>
/// <returns>翻訳語。</returns>
internal static string _(string message)
{
if (Gettext.langtag == Gettext.OriginalLocale)
{
// クライアントの言語が翻訳元の言語なら、そのまま返す
return message;
}
foreach (string langtag in new[] {
// クライアントの言語の翻訳リソースが存在すれば、それを返す
Gettext.langtag,
// 地域下位タグを取り除いた言語タグの翻訳リソースが存在すれば、それを返す
Gettext.language,
// 既定言語の翻訳リソースが存在すれば、それを返す
Gettext.DefaultLocale,
})
{
if (Gettext.multilingualLocalizedTexts.ContainsKey(key: langtag)
&& Gettext.multilingualLocalizedTexts[Gettext.langtag].ContainsKey(key: message)
&& Gettext.multilingualLocalizedTexts[Gettext.langtag][message] != "")
{
return Gettext.multilingualLocalizedTexts[Gettext.langtag][message];
}
}
return message;
}
/// <summary>
/// <see cref="SystemLanguage"/>に対応するIETF言語タグを返します。
/// </summary>
/// <param name="systemLanguage"></param>
/// <returns><see cref="SystemLanguage.Unknown"/>の場合は「und」、未知の<see cref="SystemLanguage"/>の場合は空文字列を返します。</returns>
private static string ConvertToLangtagFromSystemLanguage(SystemLanguage systemLanguage)
{
switch (systemLanguage)
{
case SystemLanguage.Afrikaans:
return "af";
case SystemLanguage.Arabic:
return "ar";
case SystemLanguage.Basque:
return "eu";
case SystemLanguage.Belarusian:
return "be";
case SystemLanguage.Bulgarian:
return "bg";
case SystemLanguage.Catalan:
return "ca";
case SystemLanguage.Chinese:
return "zh";
case SystemLanguage.Czech:
return "cs";
case SystemLanguage.Danish:
return "da";
case SystemLanguage.Dutch:
return "nl";
case SystemLanguage.English:
return "en";
case SystemLanguage.Estonian:
return "et";
case SystemLanguage.Faroese:
return "fo";
case SystemLanguage.Finnish:
return "fi";
case SystemLanguage.French:
return "fr";
case SystemLanguage.German:
return "de";
case SystemLanguage.Greek:
return "el";
case SystemLanguage.Hebrew:
return "he";
case SystemLanguage.Hungarian:
return "hu";
case SystemLanguage.Icelandic:
return "is";
case SystemLanguage.Indonesian:
return "in";
case SystemLanguage.Italian:
return "it";
case SystemLanguage.Japanese:
return "ja";
case SystemLanguage.Korean:
return "ko";
case SystemLanguage.Latvian:
return "lv";
case SystemLanguage.Lithuanian:
return "lt";
case SystemLanguage.Norwegian:
return "no";
case SystemLanguage.Polish:
return "pl";
case SystemLanguage.Portuguese:
return "pt";
case SystemLanguage.Romanian:
return "ro";
case SystemLanguage.Russian:
return "ru";
case SystemLanguage.SerboCroatian:
return "sh";
case SystemLanguage.Slovak:
return "sk";
case SystemLanguage.Slovenian:
return "sl";
case SystemLanguage.Spanish:
return "es";
case SystemLanguage.Swedish:
return "sv";
case SystemLanguage.Thai:
return "th";
case SystemLanguage.Turkish:
return "tr";
case SystemLanguage.Ukrainian:
return "uk";
case SystemLanguage.Vietnamese:
return "vi";
case SystemLanguage.ChineseSimplified:
return "zh-Hans";
case SystemLanguage.ChineseTraditional:
return "zh-Hant";
case SystemLanguage.Unknown:
return "und";
}
return "";
}
}
}
fileFormatVersion: 2
guid: 9720e1625fb5dd449b90c335405a91a5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment