diff --git a/SparkleShare.sln b/SparkleShare.sln index 40b22bd55..8b7526e76 100644 --- a/SparkleShare.sln +++ b/SparkleShare.sln @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SparkleShare.Windows", "Spa EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SparkleShare.Linux", "SparkleShare\Linux\SparkleShare.Linux.csproj", "{5714D3CA-88A6-4330-A29D-4CA90D1D193C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sparkles.Subversion", "Sparkles\Subversion\Sparkles.Subversion.csproj", "{009FDCD7-1D57-4202-BB6D-8477D8C6AAAA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Release|Any CPU = Release|Any CPU @@ -48,6 +50,12 @@ Global {CF5BC8DB-A633-4FCC-8A3E-E3AC9B59FABC}.Release|Any CPU.Build.0 = Release|Any CPU {CF5BC8DB-A633-4FCC-8A3E-E3AC9B59FABC}.ReleaseDist|Any CPU.ActiveCfg = ReleaseDist|Any CPU {CF5BC8DB-A633-4FCC-8A3E-E3AC9B59FABC}.ReleaseDist|Any CPU.Build.0 = ReleaseDist|Any CPU + {009FDCD7-1D57-4202-BB6D-8477D8C6AAAA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {009FDCD7-1D57-4202-BB6D-8477D8C6AAAA}.Release|Any CPU.Build.0 = Release|Any CPU + {009FDCD7-1D57-4202-BB6D-8477D8C6AAAA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {009FDCD7-1D57-4202-BB6D-8477D8C6AAAA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {009FDCD7-1D57-4202-BB6D-8477D8C6AAAA}.ReleaseDist|Any CPU.ActiveCfg = Release|Any CPU + {009FDCD7-1D57-4202-BB6D-8477D8C6AAAA}.ReleaseDist|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(MonoDevelopProperties) = preSolution Policies = $0 diff --git a/SparkleShare/Common/BaseController.cs b/SparkleShare/Common/BaseController.cs index 8ca074661..febd5d2ae 100644 --- a/SparkleShare/Common/BaseController.cs +++ b/SparkleShare/Common/BaseController.cs @@ -223,6 +223,7 @@ public virtual void Initialize () Logger.LogInfo ("Environment", "SparkleShare " + version); Logger.LogInfo ("Environment", "Git LFS " + Sparkles.Git.GitCommand.GitLFSVersion); Logger.LogInfo ("Environment", "Git " + Sparkles.Git.GitCommand.GitVersion); + Logger.LogInfo ("Environment", "Svn " + Sparkles.Subversion.SubversionCommand.SvnVersion); // TODO: ToString() with nice os version names (Mac OS X Yosemite, Fedora 24, Ubuntu 16.04, etc.) Logger.LogInfo ("Environment", InstallationInfo.OperatingSystem + " (" + Environment.OSVersion + ")"); diff --git a/SparkleShare/Common/Images/Sources/Subversion-logo.svg b/SparkleShare/Common/Images/Sources/Subversion-logo.svg new file mode 100644 index 000000000..b40692206 --- /dev/null +++ b/SparkleShare/Common/Images/Sources/Subversion-logo.svg @@ -0,0 +1,34 @@ + + +image/svg+xmlsubversionlogosubversion logoSept 2010Franziska SponselFranziska SponselRRZEHendrik Eggers, Beate Kaspar + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SparkleShare/Common/Presets/Makefile.am b/SparkleShare/Common/Presets/Makefile.am index 40efbe968..5bb9f49c3 100644 --- a/SparkleShare/Common/Presets/Makefile.am +++ b/SparkleShare/Common/Presets/Makefile.am @@ -4,14 +4,15 @@ dist_presets_DATA = \ gitlab.xml \ own-server.xml \ planio.xml \ + subversion.xml \ github.png \ gitlab.png \ bitbucket.png \ planio.png \ + subversion.png \ own-server.png presetsdir = $(pkgdatadir)/presets/ MAINTAINERCLEANFILES = \ Makefile.in - diff --git a/SparkleShare/Common/Presets/subversion.png b/SparkleShare/Common/Presets/subversion.png new file mode 100644 index 000000000..bd180dc78 Binary files /dev/null and b/SparkleShare/Common/Presets/subversion.png differ diff --git a/SparkleShare/Common/Presets/subversion.xml b/SparkleShare/Common/Presets/subversion.xml new file mode 100644 index 000000000..09164a42a --- /dev/null +++ b/SparkleShare/Common/Presets/subversion.xml @@ -0,0 +1,19 @@ + + + + + Subversion Server + Subversion Server + subversion.png + Subversion + +
+ + svn://subversion.somewhere.com +
+ + + path/to/folder/you/want/as/root + +
+
diff --git a/SparkleShare/Common/Presets/subversion@2x.png b/SparkleShare/Common/Presets/subversion@2x.png new file mode 100644 index 000000000..2f1ab442a Binary files /dev/null and b/SparkleShare/Common/Presets/subversion@2x.png differ diff --git a/SparkleShare/Linux/SparkleShare.Linux.csproj b/SparkleShare/Linux/SparkleShare.Linux.csproj index f93dc8249..c7d7d06d0 100644 --- a/SparkleShare/Linux/SparkleShare.Linux.csproj +++ b/SparkleShare/Linux/SparkleShare.Linux.csproj @@ -49,6 +49,10 @@ {009FDCD7-1D57-4202-BB6D-8477D8C6B8EE} Sparkles.Git + + {009FDCD7-1D57-4202-BB6D-8477D8C6AAAA} + Sparkles.Subversion + {2C914413-B31C-4362-93C7-1AE34F09112A} Sparkles @@ -117,6 +121,15 @@ Presets\planio%402x.png + + Presets\subversion.png + + + Presets\subversion.xml + + + Presets\subversion%402x.png + @@ -178,4 +191,4 @@ - \ No newline at end of file + diff --git a/SparkleShare/Mac/SparkleShare.Mac.csproj b/SparkleShare/Mac/SparkleShare.Mac.csproj index 0e1b5cbb6..4cd987aa0 100644 --- a/SparkleShare/Mac/SparkleShare.Mac.csproj +++ b/SparkleShare/Mac/SparkleShare.Mac.csproj @@ -56,6 +56,7 @@ false false false + None false @@ -244,6 +245,15 @@ Presets\planio%402x.png + + Presets\subversion.png + + + Presets\subversion.xml + + + Presets\subversion%402x.png + Resources\text-balloon.png @@ -278,5 +288,9 @@ {009FDCD7-1D57-4202-BB6D-8477D8C6B8EE} Sparkles.Git + + {009FDCD7-1D57-4202-BB6D-8477D8C6AAAA} + Sparkles.Subversion + diff --git a/SparkleShare/Windows/SparkleShare.Windows.csproj b/SparkleShare/Windows/SparkleShare.Windows.csproj index 9a4da88a3..356c7473a 100644 --- a/SparkleShare/Windows/SparkleShare.Windows.csproj +++ b/SparkleShare/Windows/SparkleShare.Windows.csproj @@ -215,6 +215,10 @@ Presets\planio.png Always + + Presets\subversion.png + Always + @@ -241,6 +245,10 @@ Presets\planio.xml Always + + Presets\subversion.xml + Always + diff --git a/Sparkles/BaseFetcher.cs b/Sparkles/BaseFetcher.cs index 857c16846..e3316c520 100644 --- a/Sparkles/BaseFetcher.cs +++ b/Sparkles/BaseFetcher.cs @@ -237,6 +237,10 @@ public static string GetBackend (string address) return char.ToUpper (backend [0]) + backend.Substring (1); } + if (address.StartsWith ("svn", StringComparison.InvariantCultureIgnoreCase)) { + return "Subversion"; + } + return "Git"; } diff --git a/Sparkles/Command.cs b/Sparkles/Command.cs index 3bda6b3c7..080b51547 100644 --- a/Sparkles/Command.cs +++ b/Sparkles/Command.cs @@ -54,7 +54,7 @@ public Command (string path, string args, bool write_output) if (!string.IsNullOrEmpty (StartInfo.WorkingDirectory)) folder = Path.GetFileName (StartInfo.WorkingDirectory) + " | "; - + if (write_output) Logger.LogInfo ("Cmd", folder + Path.GetFileName (StartInfo.FileName) + " " + StartInfo.Arguments); diff --git a/Sparkles/Subversion/Makefile.am b/Sparkles/Subversion/Makefile.am new file mode 100755 index 000000000..2c2716f60 --- /dev/null +++ b/Sparkles/Subversion/Makefile.am @@ -0,0 +1,21 @@ +ASSEMBLY = Sparkles.Git +TARGET = library + +LINK = -r:$(DIR_BIN)/Sparkles.dll + +SOURCES = \ + SubversionCommand.cs \ + SubversionFetcher.cs \ + SubversionRepository.cs + +install-data-hook: + for ASM in $(EXTRA_BUNDLE); do \ + $(INSTALL) -m 0755 $$ASM $(DESTDIR)$(moduledir); \ + done; + +uninstall-hook: + for ASM in $(EXTRA_BUNDLE); do \ + rm -f $(DESTDIR)$(moduledir)/`basename $$ASM`; \ + done; + +include $(top_srcdir)/build/build.mk diff --git a/Sparkles/Subversion/Sparkles.Subversion.csproj b/Sparkles/Subversion/Sparkles.Subversion.csproj new file mode 100644 index 000000000..8289ca2f4 --- /dev/null +++ b/Sparkles/Subversion/Sparkles.Subversion.csproj @@ -0,0 +1,60 @@ + + + + Debug + AnyCPU + 8.0.30703 + 2.0 + {009FDCD7-1D57-4202-BB6D-8477D8C6AAAA} + Library + Properties + Sparkles.Subversion + Sparkles.Subversion + 512 + + + v4.5 + + + True + ..\..\bin\ + prompt + 4 + + + False + bin\Debug + 4 + TRACE DEBUG + true + + + + + + + + + + + + + + + + + + + + + {2C914413-B31C-4362-93C7-1AE34F09112A} + Sparkles + + + diff --git a/Sparkles/Subversion/SubversionCommand.cs b/Sparkles/Subversion/SubversionCommand.cs new file mode 100644 index 000000000..bdcad553d --- /dev/null +++ b/Sparkles/Subversion/SubversionCommand.cs @@ -0,0 +1,110 @@ +// SparkleShare, a collaboration and sharing tool. +// Copyright (C) 2010 Hylke Bons +// Portions Copyright (C) 2016 Paul Hammant +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +using System; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Sparkles.Subversion { + + public class SubversionCommand : Command { + + public static string ExecPath; + + + static string svn_path; + + public static string SvnPath { + get { + if (svn_path == null) + svn_path = LocateCommand ("svn"); + + return svn_path; + } + + set { + svn_path = value; + } + } + + + public static string SvnVersion { + get { + if (SvnPath == null) + SvnPath = LocateCommand ("svn"); + + var svn_version = new Command (SvnPath, "--version", false); + + if (ExecPath != null) + svn_version.SetEnvironmentVariable ("SVN_EXEC_PATH", ExecPath); + + string version = svn_version.StartAndReadStandardOutput (); + return version.Substring(0, version.IndexOf("\n")).Replace ("svn, version ", ""); + } + } + + public SubversionCommand (string working_dir, string args) : this (working_dir, args, null) + { + } + + + public SubversionCommand (string working_dir, string args, SSHAuthenticationInfo auth_info) : base (SvnPath, args) + { + + StartInfo.WorkingDirectory = working_dir; + + if (ExecPath != null) + SetEnvironmentVariable ("SVN_EXEC_PATH", ExecPath); + + SetEnvironmentVariable ("LANG", "en_US"); + + } + + + static ErrorStatus FindError (string line) + { + ErrorStatus error = ErrorStatus.None; + + if (line.Contains ("WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!") || + line.Contains ("WARNING: POSSIBLE DNS SPOOFING DETECTED!")) { + + error = ErrorStatus.HostIdentityChanged; + } + + if (line.StartsWith ("Permission denied") || + line.StartsWith ("ssh_exchange_identification: Connection closed by remote host") || + line.StartsWith ("The authenticity of host")) { + + error = ErrorStatus.AuthenticationFailed; + } + + if (line.EndsWith ("does not appear to be a git repository")) + error = ErrorStatus.NotFound; + + if (line.EndsWith ("expected old/new/ref, got 'shallow")) + error = ErrorStatus.IncompatibleClientServer; + + if (line.StartsWith ("error: Disk space exceeded") || + line.EndsWith ("No space left on device") || + line.EndsWith ("file write error (Disk quota exceeded)")) { + + error = ErrorStatus.DiskSpaceExceeded; + } + + return error; + } + } +} diff --git a/Sparkles/Subversion/SubversionFetcher.cs b/Sparkles/Subversion/SubversionFetcher.cs new file mode 100644 index 000000000..16c1bf1e4 --- /dev/null +++ b/Sparkles/Subversion/SubversionFetcher.cs @@ -0,0 +1,176 @@ +// SparkleShare, a collaboration and sharing tool. +// Copyright (C) 2010 Hylke Bons +// Portions Copyright (C) 2016 Paul Hammant +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + + +using System; +using System.Globalization; +using System.IO; +using System.Text.RegularExpressions; +using System.Threading; + +namespace Sparkles.Subversion { + + public class SubversionFetcher : SSHFetcher { + + SubversionCommand svn_checkout; + SSHAuthenticationInfo auth_info; + + protected override bool IsFetchedRepoEmpty { + get { + var svn_rev_parse = new SubversionCommand (TargetFolder, "ls -v -r"); + svn_rev_parse.StartAndWaitForExit (); + + return (svn_rev_parse.ExitCode != 0); + } + } + + + public SubversionFetcher (SparkleFetcherInfo fetcher_info, SSHAuthenticationInfo auth_info) : base (fetcher_info) + { + + this.auth_info = auth_info; + var uri_builder = new UriBuilder (RemoteUrl); + + uri_builder.Scheme = "svn"; + + RemoteUrl = uri_builder.Uri; + + AvailableStorageTypes.Add ( + new StorageTypeInfo (StorageType.Encrypted, "Encrypted Storage", + "Trade off efficiency for privacy;\nencrypts before storing files on the host")); + } + + + public override bool Fetch () + { + if (!base.Fetch ()) + return false; + + StorageType? storage_type = StorageType.Plain; + + if (storage_type == null) + return false; + + FetchedRepoStorageType = (StorageType) storage_type; + + string svn_checkout_command = "checkout"; + + + svn_checkout = new SubversionCommand (Configuration.DefaultConfiguration.TmpPath, + string.Format ("{0} \"{1}\" \"{2}\"", svn_checkout_command, RemoteUrl, TargetFolder), + auth_info); + + svn_checkout.StartInfo.RedirectStandardError = true; + svn_checkout.Start (); + + StreamReader output_stream = svn_checkout.StandardError; + + if (FetchedRepoStorageType == StorageType.LargeFiles) + output_stream = svn_checkout.StandardOutput; + + double percentage = 0; + double speed = 0; + string information = ""; + + while (!output_stream.EndOfStream) { + string line = output_stream.ReadLine (); + + OnProgressChanged (percentage, speed, information); + } + + svn_checkout.WaitForExit (); + + if (svn_checkout.ExitCode != 0) + return false; + + Thread.Sleep (500); + OnProgressChanged (100, 0, ""); + Thread.Sleep (500); + + return true; + } + + + public override void Stop () + { + try { + if (svn_checkout != null && !svn_checkout.HasExited) { + svn_checkout.Kill (); + svn_checkout.Dispose (); + } + + } catch (Exception e) { + Logger.LogInfo ("Fetcher", "Failed to dispose properly", e); + } + + if (Directory.Exists (TargetFolder)) { + try { + Directory.Delete (TargetFolder, true /* Recursive */ ); + Logger.LogInfo ("Fetcher", "Deleted '" + TargetFolder + "'"); + + } catch (Exception e) { + Logger.LogInfo ("Fetcher", "Failed to delete '" + TargetFolder + "'", e); + } + } + } + + + public override string Complete (StorageType selected_storage_type) + { + string identifier = base.Complete (selected_storage_type); + string identifier_path = Path.Combine (TargetFolder, ".sparkleshare"); + + File.WriteAllText (identifier_path, identifier); + + var svn_add = new SubversionCommand (TargetFolder, "add .sparkleshare"); + var svn_commit = new SubversionCommand (TargetFolder, "commit -m \"Initial commit by SparkleShare\""); + + // We can't do the "commit --all" shortcut because it doesn't add untracked files + svn_add.StartAndWaitForExit (); + svn_commit.StartAndWaitForExit (); + + + File.SetAttributes (identifier_path, FileAttributes.Hidden); + return identifier; + } + + + public override void EnableFetchedRepoCrypto (string password) + { + } + + + public override bool IsFetchedRepoPasswordCorrect (string password) + { + return true; + } + + + public override string FormatName () + { + string name = Path.GetFileName (RemoteUrl.AbsolutePath); + name = name.ReplaceUnderscoreWithSpace (); + + if (name.EndsWith (".svn")) + name = name.Replace (".svn", ""); + + return name; + } + + + } +} diff --git a/Sparkles/Subversion/SubversionRepository.cs b/Sparkles/Subversion/SubversionRepository.cs new file mode 100644 index 000000000..d3eb695df --- /dev/null +++ b/Sparkles/Subversion/SubversionRepository.cs @@ -0,0 +1,701 @@ +// SparkleShare, a collaboration and sharing tool. +// Copyright (C) 2010 Hylke Bons +// Portions Copyright (C) 2016 Paul Hammant + +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; + +namespace Sparkles.Subversion { + + public class SubversionRepository : BaseRepository { + + SSHAuthenticationInfo auth_info; + + bool in_merge { + get { + string merge_file_path = Path.Combine (LocalPath, ".svn", "MERGE_HEAD"); + return File.Exists (merge_file_path); + } + } + + + public SubversionRepository (string path, Configuration config, SSHAuthenticationInfo auth_info) : base (path, config) + { + this.auth_info = auth_info; + } + + + public override List ExcludePaths { + get { + List rules = new List (); + rules.Add (".svn"); + + return rules; + } + } + + + public override double Size { + get { + // TODO + return 0; + } + } + + + public override double HistorySize { + get { + // TODO + return 0; + } + } + + + public override string CurrentRevision { + get { + var svn = new SubversionCommand (LocalPath, "info --show-item revision"); + string output = svn.StartAndReadStandardOutput (); + + if (svn.ExitCode == 0) + return output; + + return null; + } + } + + + public override bool HasRemoteChanges { + get { + Logger.LogInfo ("Svn", Name + " | Checking for remote changes..."); + string current_revision = CurrentRevision; + + var svn = new SubversionCommand (LocalPath, + "info --show-item revision " + RemoteUrl, auth_info); + + string output = svn.StartAndReadStandardOutput (); + + if (svn.ExitCode != 0) + return false; + + string remote_revision = output; + + if (!remote_revision.Equals (current_revision)) { + return true; + } + + Logger.LogInfo ("Svn", Name + " | No remote changes, local+remote: " + current_revision); + return false; + } + } + + + public override bool SyncUp () + { + if (!Add ()) { + Error = ErrorStatus.UnreadableFiles; + return false; + } + + string message = base.status_message.Replace ("\"", "\\\""); + + if (string.IsNullOrEmpty (message)) + message = FormatCommitMessage (); + + var svn_commit = new SubversionCommand (LocalPath, string.Format ("commit -m=\"" + message + "\" ", RemoteUrl), auth_info); + svn_commit.StartInfo.RedirectStandardError = true; + svn_commit.Start (); + + if (!ReadStream (svn_commit)) + return false; + + svn_commit.WaitForExit (); + + if (svn_commit.ExitCode == 0) + return true; + + Error = ErrorStatus.HostUnreachable; + return false; + } + + + public override bool SyncDown () + { + + var svn_up = new SubversionCommand (LocalPath, "up", auth_info); + + svn_up.StartInfo.RedirectStandardError = true; + svn_up.Start (); + + if (!ReadStream (svn_up)) + return false; + + svn_up.WaitForExit (); + + if (svn_up.ExitCode != 0) { + Error = ErrorStatus.HostUnreachable; + return false; + } + + return true; + } + + + bool ReadStream (SubversionCommand command) + { + + StreamReader output_stream = command.StandardError; + + string information = ""; + + while (!output_stream.EndOfStream) { + string line = output_stream.ReadLine (); + + OnProgressChanged (0, 0, information); + } + + return true; + } + + + public override bool HasLocalChanges { + get { + PrepareDirectories (LocalPath); + + var svn = new SubversionCommand (LocalPath, "status"); + string output = svn.StartAndReadStandardOutput (); + + return !string.IsNullOrEmpty (output); + } + } + + + public override bool HasUnsyncedChanges { + // TODO + get { + return false; + } + set { + } + } + + // Stages the made changes + bool Add () + { + + var svn = new SubversionCommand (LocalPath, "status " + LocalPath); + string output = svn.StartAndReadStandardOutput (); + int count = 0; + using (StringReader reader = new StringReader (output)) { + string line; + while ((line = reader.ReadLine ()) != null) { + if (line.StartsWith ("? ")) { + new SubversionCommand (LocalPath, "add " + line.Substring (7)).Start (); + count++; + } else if (line.StartsWith ("M ")) { + // Subversion is going to act on this by default + count++; + } else if (line.StartsWith ("! ")) { + new SubversionCommand (LocalPath, "delete " + line.Substring (7)).Start (); + count++; + } + //TODO see if Add and Delete are on the same item, and do a 'move --force' instead + } + } + + return count > 0; + } + + + public override void RestoreFile (string path, string revision, string target_file_path) + { + if (path == null) + throw new ArgumentNullException ("path"); + + if (revision == null) + throw new ArgumentNullException ("revision"); + + Logger.LogInfo ("Svn", Name + " | Restoring \"" + path + "\" (revision " + revision + ")"); + + // Restore the older file... + var svn = new SubversionCommand (LocalPath, "checkout -r" + revision + " \"" + path + "\""); + svn.StartAndWaitForExit (); + + string local_file_path = Path.Combine (LocalPath, path); + + // ...move it... + try { + File.Move (local_file_path, target_file_path); + + } catch { + Logger.LogInfo ("Svn", + Name + " | Could not move \"" + local_file_path + "\" to \"" + target_file_path + "\""); + } + + // ...and restore the most recent revision + svn = new SubversionCommand (LocalPath, "checkout -r" + CurrentRevision + " \"" + path + "\""); + svn.StartAndWaitForExit (); + + + if (target_file_path.StartsWith (LocalPath)) + new Thread (() => OnFileActivity (null)).Start (); + } + + + public override List UnsyncedChanges { + get { + return ParseStatus (); + } + } + + + public override List GetChangeSets () + { + return GetChangeSetsInternal (null); + } + + public override List GetChangeSets (string path) + { + return GetChangeSetsInternal (path); + } + + List GetChangeSetsInternal (string path) + { + var change_sets = new List (); + SubversionCommand svn; + + svn = new SubversionCommand (LocalPath, "log -l 100"); + + string output = svn.StartAndReadStandardOutput (); + + string [] lines = output.Split ("\n".ToCharArray ()); + List entries = new List (); + + // Split up commit entries + int line_number = 0; + string entry = ""; + + while (lines.Length <= line_number) { + if (lines [line_number].StartsWith ("-------")) { + if (lines [line_number + 1].StartsWith ("r")) { + entry = lines [line_number + 1]; + entry = entry + " <###> " + lines [line_number + 3]; + line_number += 3; + entries.Add (entry); + } + line_number += 1; + } + } + + // Parse commit entries + foreach (string log_entry in entries) { + Match match = this.log_regex.Match (log_entry); + + if (!match.Success) { + match = this.merge_regex.Match (log_entry); + + if (!match.Success) + continue; + } + + ChangeSet change_set = new ChangeSet (); + + change_set.Folder = new SparkleFolder (Name); + change_set.Revision = match.Groups [1].Value; + change_set.User = new User (match.Groups [2].Value, match.Groups [3].Value); + change_set.RemoteUrl = RemoteUrl; + + change_set.Timestamp = new DateTime (int.Parse (match.Groups [4].Value), + int.Parse (match.Groups [5].Value), int.Parse (match.Groups [6].Value), + int.Parse (match.Groups [7].Value), int.Parse (match.Groups [8].Value), + int.Parse (match.Groups [9].Value)); + + string time_zone = match.Groups [10].Value; + int our_offset = TimeZone.CurrentTimeZone.GetUtcOffset(DateTime.Now).Hours; + int their_offset = int.Parse (time_zone.Substring (0, 3)); + change_set.Timestamp = change_set.Timestamp.AddHours (their_offset * -1); + change_set.Timestamp = change_set.Timestamp.AddHours (our_offset); + + string [] entry_lines = log_entry.Split ("\n".ToCharArray ()); + + // Parse file list. Lines containing file changes start with ":" + foreach (string entry_line in entry_lines) { + // Skip lines containing backspace characters + if (!entry_line.StartsWith (":") || entry_line.Contains ("\\177")) + continue; + + string file_path = entry_line.Substring (39); + + if (file_path.Equals (".sparkleshare")) + continue; + + string type_letter = entry_line [37].ToString (); + bool change_is_folder = false; + + if (file_path.EndsWith (".empty")) { + file_path = file_path.Substring (0, file_path.Length - ".empty".Length); + change_is_folder = true; + } + + try { + file_path = EnsureSpecialCharacters (file_path); + + } catch (Exception e) { + Logger.LogInfo ("Local", "Error parsing file name '" + file_path + "'", e); + continue; + } + + file_path = file_path.Replace ("\\\"", "\""); + + Change change = new Change () { + Path = file_path, + IsFolder = change_is_folder, + Timestamp = change_set.Timestamp, + Type = ChangeType.Added + }; + + if (type_letter.Equals ("R")) { + int tab_pos = entry_line.LastIndexOf ("\t"); + file_path = entry_line.Substring (42, tab_pos - 42); + string to_file_path = entry_line.Substring (tab_pos + 1); + + try { + file_path = EnsureSpecialCharacters (file_path); + + } catch (Exception e) { + Logger.LogInfo ("Local", "Error parsing file name '" + file_path + "'", e); + continue; + } + + try { + to_file_path = EnsureSpecialCharacters (to_file_path); + + } catch (Exception e) { + Logger.LogInfo ("Local", "Error parsing file name '" + to_file_path + "'", e); + continue; + } + + file_path = file_path.Replace ("\\\"", "\""); + to_file_path = to_file_path.Replace ("\\\"", "\""); + + if (file_path.EndsWith (".empty")) { + file_path = file_path.Substring (0, file_path.Length - 6); + change_is_folder = true; + } + + if (to_file_path.EndsWith (".empty")) { + to_file_path = to_file_path.Substring (0, to_file_path.Length - 6); + change_is_folder = true; + } + + change.Path = file_path; + change.MovedToPath = to_file_path; + change.Type = ChangeType.Moved; + + } else if (type_letter.Equals ("M")) { + change.Type = ChangeType.Edited; + + } else if (type_letter.Equals ("D")) { + change.Type = ChangeType.Deleted; + } + + change_set.Changes.Add (change); + } + + // Group commits per user, per day + if (change_sets.Count > 0 && path == null) { + ChangeSet last_change_set = change_sets [change_sets.Count - 1]; + + if (change_set.Timestamp.Year == last_change_set.Timestamp.Year && + change_set.Timestamp.Month == last_change_set.Timestamp.Month && + change_set.Timestamp.Day == last_change_set.Timestamp.Day && + change_set.User.Name.Equals (last_change_set.User.Name)) { + + last_change_set.Changes.AddRange (change_set.Changes); + + if (DateTime.Compare (last_change_set.Timestamp, change_set.Timestamp) < 1) { + last_change_set.FirstTimestamp = last_change_set.Timestamp; + last_change_set.Timestamp = change_set.Timestamp; + last_change_set.Revision = change_set.Revision; + + } else { + last_change_set.FirstTimestamp = change_set.Timestamp; + } + + } else { + change_sets.Add (change_set); + } + + } else { + // Don't show removals or moves in the revision list of a file + if (path != null) { + List changes_to_skip = new List (); + + foreach (Change change in change_set.Changes) { + if ((change.Type == ChangeType.Deleted || change.Type == ChangeType.Moved) + && change.Path.Equals (path)) { + + changes_to_skip.Add (change); + } + } + + foreach (Change change_to_skip in changes_to_skip) + change_set.Changes.Remove (change_to_skip); + } + + change_sets.Add (change_set); + } + } + + return change_sets; + } + + + string EnsureSpecialCharacters (string path) + { + // The path is quoted if it contains special characters + if (path.StartsWith ("\"")) + path = ResolveSpecialChars (path.Substring (1, path.Length - 2)); + + return path; + } + + + string ResolveSpecialChars (string s) + { + StringBuilder builder = new StringBuilder (s.Length); + List codes = new List (); + + for (int i = 0; i < s.Length; i++) { + while (s [i] == '\\' && + s.Length - i > 3 && + char.IsNumber (s [i + 1]) && + char.IsNumber (s [i + 2]) && + char.IsNumber (s [i + 3])) { + + codes.Add (Convert.ToByte (s.Substring (i + 1, 3), 8)); + i += 4; + } + + if (codes.Count > 0) { + builder.Append (Encoding.UTF8.GetString (codes.ToArray ())); + codes.Clear (); + } + + builder.Append (s [i]); + } + + return builder.ToString (); + } + + + // Git doesn't track empty directories, so this method + // fills them all with a hidden empty file. + // + // It also prevents git repositories from becoming + // git submodules by renaming the .git/HEAD file + void PrepareDirectories (string path) + { + try { + foreach (string child_path in Directory.GetDirectories (path)) { + if (IsSymlink (child_path)) + continue; + + if (child_path.EndsWith (".svn")) { + if (child_path.Equals (Path.Combine (LocalPath, ".svn"))) + continue; + + string HEAD_file_path = Path.Combine (child_path, "HEAD"); + + if (File.Exists (HEAD_file_path)) { + File.Move (HEAD_file_path, HEAD_file_path + ".backup"); + Logger.LogInfo ("Svn", Name + " | Renamed " + HEAD_file_path); + } + + continue; + } + + PrepareDirectories (child_path); + } + + if (Directory.GetFiles (path).Length == 0 && + Directory.GetDirectories (path).Length == 0 && + !path.Equals (LocalPath)) { + + if (!File.Exists (Path.Combine (path, ".empty"))) { + try { + File.WriteAllText (Path.Combine (path, ".empty"), "I'm a folder!"); + File.SetAttributes (Path.Combine (path, ".empty"), FileAttributes.Hidden); + + } catch { + Logger.LogInfo ("Svn", Name + " | Failed adding empty folder " + path); + } + } + } + + } catch (IOException e) { + Logger.LogInfo ("Svn", "Failed preparing directory", e); + } + } + + + List ParseStatus () + { + List changes = new List (); + + var svn_status = new SubversionCommand (LocalPath, "status " + LocalPath); + svn_status.Start (); + + while (!svn_status.StandardOutput.EndOfStream) { + string line = svn_status.StandardOutput.ReadLine (); + + line = line.Trim (); + Console.WriteLine ("LL " + line); + + Change change; + + if (line.StartsWith ("R")) { + string path = line.Substring (3, line.IndexOf (" -> ") - 3).Trim ("\" ".ToCharArray ()); + string moved_to_path = line.Substring (line.IndexOf (" -> ") + 4).Trim ("\" ".ToCharArray ()); + + change = new Change () { + Type = ChangeType.Moved, + Path = EnsureSpecialCharacters (path), + MovedToPath = EnsureSpecialCharacters (moved_to_path) + }; + + } else { + string path = line.Substring (2).Trim ("\" ".ToCharArray ()); + change = new Change () { Path = EnsureSpecialCharacters (path) }; + change.Type = ChangeType.Added; + + if (line.StartsWith ("M")) { + change.Type = ChangeType.Edited; + + } else if (line.StartsWith ("D")) { + change.Type = ChangeType.Deleted; + } + } + + changes.Add (change); + } + + svn_status.StandardOutput.ReadToEnd (); + svn_status.WaitForExit (); + + return changes; + } + + + // Creates a pretty commit message based on what has changed + string FormatCommitMessage () + { + string message = ""; + + foreach (Change change in ParseStatus ()) { + if (change.Type == ChangeType.Moved) { + message += "< ‘" + EnsureSpecialCharacters (change.Path) + "’\n"; + message += "> ‘" + EnsureSpecialCharacters (change.MovedToPath) + "’\n"; + + } else { + switch (change.Type) { + case ChangeType.Edited: + message += "/"; + break; + case ChangeType.Deleted: + message += "-"; + break; + case ChangeType.Added: + message += "+"; + break; + } + + message += " ‘" + change.Path + "’\n"; + } + } + + if (string.IsNullOrWhiteSpace (message)) + return null; + else + return message; + } + + + // Recursively gets a folder's size in bytes + long CalculateSizes (DirectoryInfo parent) + { + long size = 0; + + try { + foreach (DirectoryInfo directory in parent.GetDirectories ()) { + if (directory.FullName.IsSymlink () || + directory.Name.Equals (".svn")) { + + continue; + } + + size += CalculateSizes (directory); + } + + } catch (Exception e) { + Logger.LogInfo ("Local", "Error calculating directory size", e); + } + + try { + foreach (FileInfo file in parent.GetFiles ()) { + if (file.FullName.IsSymlink ()) + continue; + + if (file.Name.Equals (".empty")) + File.SetAttributes (file.FullName, FileAttributes.Hidden); + else + size += file.Length; + } + + } catch (Exception e) { + Logger.LogInfo ("Local", "Error calculating file size", e); + } + + return size; + } + + + bool IsSymlink (string file) + { + FileAttributes attributes = File.GetAttributes (file); + return ((attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint); + } + + + Regex log_regex = new Regex (@"commit ([a-f0-9]{40})*\n" + + "Author: (.+) <(.+)>\n" + + "Date: ([0-9]{4})-([0-9]{2})-([0-9]{2}) " + + "([0-9]{2}):([0-9]{2}):([0-9]{2}) (.[0-9]{4})\n" + + "*", RegexOptions.Compiled); + + Regex merge_regex = new Regex (@"commit ([a-f0-9]{40})\n" + + "Merge: [a-f0-9]{7} [a-f0-9]{7}\n" + + "Author: (.+) <(.+)>\n" + + "Date: ([0-9]{4})-([0-9]{2})-([0-9]{2}) " + + "([0-9]{2}):([0-9]{2}):([0-9]{2}) (.[0-9]{4})\n" + + "*", RegexOptions.Compiled); + } +}