//   SparkleShare, a collaboration and sharing tool.
//   Copyright (C) 2010  Hylke Bons <hi@planetpeanut.uk>
//
//   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 <http://www.gnu.org/licenses/>.


using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;

namespace Sparkles.Git {

    public class GitRepository : BaseRepository {

        SSHAuthenticationInfo auth_info;
        bool user_is_set;


        string cached_branch;

        string branch {
            get {
                if (!string.IsNullOrEmpty (this.cached_branch))
                    return this.cached_branch;

                var git = new GitCommand (LocalPath, "config core.ignorecase true");
                git.StartAndWaitForExit ();

                // TODO: ugly
                while (this.in_merge && HasLocalChanges) {
                    try {
                        ResolveConflict ();

                    } catch (IOException e) {
                        Logger.LogInfo ("Git", Name + " | Failed to resolve conflict, trying again...", e);
                    }
                }

                git = new GitCommand (LocalPath, "config core.ignorecase false");
                git.StartAndWaitForExit ();

                git = new GitCommand (LocalPath, "rev-parse --abbrev-ref HEAD");
                this.cached_branch = git.StartAndReadStandardOutput ();

                return this.cached_branch;
            }
        }


        bool in_merge {
            get {
                string merge_file_path = Path.Combine (LocalPath, ".git", "MERGE_HEAD");
                return File.Exists (merge_file_path);
            }
        }


        public GitRepository (string path, Configuration config, SSHAuthenticationInfo auth_info) : base (path, config)
        {
            this.auth_info = auth_info;

            var git_config = new GitCommand (LocalPath, "config core.ignorecase false");
            git_config.StartAndWaitForExit ();

            git_config = new GitCommand (LocalPath, "config remote.origin.url \"" + RemoteUrl + "\"");
            git_config.StartAndWaitForExit ();

            git_config = new GitCommand (LocalPath, "config core.sshCommand " + GitCommand.FormatGitSSHCommand (auth_info));
            git_config.StartAndWaitForExit();

            PrepareGitLFS ();
        }


        public override List<string> ExcludePaths {
            get {
                List<string> rules = new List<string> ();
                rules.Add (".git");

                return rules;
            }
        }


        public override double Size {
            get {
                string file_path = Path.Combine (LocalPath, ".git", "info", "size");

                if (!File.Exists (file_path))
                    File.WriteAllText (file_path, "0");

                string size = File.ReadAllText (file_path);

                try {
                    return double.Parse (size);

                } catch (Exception e) {
                    Logger.LogInfo ("Git", Name + " | Failed to parse " + file_path, e);
                    return 0;
                }
            }
        }


        public override double HistorySize {
            get {
                string file_path = Path.Combine (LocalPath, ".git", "info", "history_size");

                if (!File.Exists (file_path))
                    File.WriteAllText (file_path, "0");

                string size = File.ReadAllText (file_path);

                try {
                    return double.Parse (size);

                } catch (Exception e) {
                    Logger.LogInfo ("Git", Name + " | Failed to parse " + file_path, e);
                    return 0;
                }
            }
        }


        void UpdateSizes ()
        {
            double size         = CalculateSizes (new DirectoryInfo (LocalPath));
            double history_size = CalculateSizes (new DirectoryInfo (Path.Combine (LocalPath, ".git")));

            string size_file_path = Path.Combine (LocalPath, ".git", "info", "size");
            string history_size_file_path = Path.Combine (LocalPath, ".git", "info", "history_size");

            File.WriteAllText (size_file_path, size.ToString ());
            File.WriteAllText (history_size_file_path, history_size.ToString ());
        }


        public override string CurrentRevision {
            get {
                var git = new GitCommand (LocalPath, "rev-parse HEAD");
                string output  = git.StartAndReadStandardOutput ();

                if (git.ExitCode == 0)
                    return output;

                return null;
            }
        }


        public override bool HasRemoteChanges {
            get {
                Logger.LogInfo ("Git", Name + " | Checking for remote changes...");
                string current_revision = CurrentRevision;

                var git = new GitCommand (LocalPath,
                    "ls-remote --heads --exit-code origin " + this.branch, auth_info);

                string output = git.StartAndReadStandardOutput ();

                if (git.ExitCode != 0)
                    return false;

                string remote_revision = "" + output.Substring (0, 40);

                if (!remote_revision.Equals (current_revision)) {
                    git = new GitCommand (LocalPath, "merge-base " + remote_revision + " master");
                    git.StartAndWaitForExit ();

                    if (git.ExitCode != 0) {
                        Logger.LogInfo ("Git", Name + " | Remote changes found, local: " +
                            current_revision + ", remote: " + remote_revision);

                        Error = ErrorStatus.None;
                        return true;

                    } else {
                        Logger.LogInfo ("Git", Name + " | Remote " + remote_revision + " is already in our history");
                        return false;
                    }
                }

                Logger.LogInfo ("Git", 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;

            if (string.IsNullOrEmpty (message))
                message = FormatCommitMessage ();

            if (message != null)
                Commit (message);

            PrepareGitLFS ();

            var git_push = new GitCommand (LocalPath, string.Format ("push --all --progress origin", RemoteUrl), auth_info);
            git_push.StartInfo.RedirectStandardError = true;
            git_push.Start ();

            if (!ReadStream (git_push))
                return false;

            git_push.WaitForExit ();

            UpdateSizes ();

            if (git_push.ExitCode == 0)
                return true;

            Error = ErrorStatus.HostUnreachable;
            return false;
        }


        public override bool SyncDown ()
        {
            string lfs_is_behind_file_path = Path.Combine (LocalPath, ".git", "lfs", "is_behind");

            if (StorageType == StorageType.LargeFiles)
                File.Create (lfs_is_behind_file_path).Close ();

            var git_fetch = new GitCommand (LocalPath, "fetch --progress origin " + branch, auth_info);

            git_fetch.StartInfo.RedirectStandardError = true;
            git_fetch.Start ();

            if (!ReadStream (git_fetch))
                return false;

            git_fetch.WaitForExit ();

            if (git_fetch.ExitCode != 0) {
                Error = ErrorStatus.HostUnreachable;
                return false;
            }

            if (Merge ()) {
                if (StorageType == StorageType.LargeFiles) {
                    // Pull LFS files manually to benefit from concurrency
                    var git_lfs_pull = new GitCommand (LocalPath, "lfs pull origin", auth_info);
                    git_lfs_pull.StartAndWaitForExit ();

                    if (git_lfs_pull.ExitCode != 0) {
                        Error = ErrorStatus.HostUnreachable;
                        return false;
                    }

                    if (File.Exists (lfs_is_behind_file_path))
                        File.Delete (lfs_is_behind_file_path);
                }

                UpdateSizes ();
                return true;
            }

            return false;
        }


        bool ReadStream (GitCommand command)
        {
            StreamReader output_stream = command.StandardError;

            if (StorageType == StorageType.LargeFiles)
                output_stream = command.StandardOutput;

            double percentage = 0;
            double speed = 0;
            string information = "";

            while (!output_stream.EndOfStream) {
                string line = output_stream.ReadLine ();
                ErrorStatus error = GitCommand.ParseProgress (line, out percentage, out speed, out information);

                if (error != ErrorStatus.None) {
                    Error = error;
                    information = line;

                    command.Kill ();
                    command.Dispose ();
                    Logger.LogInfo ("Git", Name + " | Error status changed to " + Error);

                    return false;
                }

                OnProgressChanged (percentage, speed, information);
            }

            return true;
        }


        public override bool HasLocalChanges {
            get {
                PrepareDirectories (LocalPath);

                var git = new GitCommand (LocalPath, "status --porcelain");
                string output  = git.StartAndReadStandardOutput ();

                return !string.IsNullOrEmpty (output);
            }
        }


        public override bool HasUnsyncedChanges {
            get {
                if (StorageType == StorageType.LargeFiles) {
                    string lfs_is_behind_file_path = Path.Combine (LocalPath, ".git", "lfs", "is_behind");

                    if (File.Exists (lfs_is_behind_file_path))
                        return true;
                }

                string unsynced_file_path =  Path.Combine (LocalPath, ".git", "has_unsynced_changes");
                return File.Exists (unsynced_file_path);
            }

            set {
                string unsynced_file_path = Path.Combine (LocalPath, ".git", "has_unsynced_changes");

                if (value)
                    File.WriteAllText (unsynced_file_path, "");
                else
                    File.Delete (unsynced_file_path);
            }
        }


        // Stages the made changes
        bool Add ()
        {
            var git = new GitCommand (LocalPath, "add --all");
            git.StartAndWaitForExit ();

            return (git.ExitCode == 0);
        }


        // Commits the made changes
        void Commit (string message)
        {
            GitCommand git_config;

            string user_name  = base.local_config.User.Name;
            string user_email = base.local_config.User.Email;

            if (!this.user_is_set) {
                git_config = new GitCommand (LocalPath, "config user.name \"" + user_name + "\"");
                git_config.StartAndWaitForExit ();

                git_config = new GitCommand (LocalPath, "config user.email \"" + user_email + "\"");
                git_config.StartAndWaitForExit ();

                this.user_is_set = true;
            }

            if (StorageType == StorageType.Encrypted) {
                string password_file_path = Path.Combine (LocalPath, ".git", "info", "encryption_password");
                string password = File.ReadAllText (password_file_path);

                user_name  = user_name.AESEncrypt (password);
                user_email = user_email.AESEncrypt (password);
            }

            GitCommand git_commit;
            string message_file_path = Path.Combine (LocalPath, ".git", "info", "commit_message");

            try {
                File.WriteAllText (message_file_path, message);

                // Commit from message stored in temporary file to avoid special character conflicts on the command line
                git_commit = new GitCommand (LocalPath, string.Format ("commit --all --file=\"{0}\" --author=\"{1} <{2}>\"",
		    message_file_path, user_name, user_email));

            } catch (IOException e) {
                Logger.LogInfo ("Git", Name + " | Could not create commit message file: " + message_file_path, e);

                // If committing with a temporary file fails, use a simple static commit message
                git_commit = new GitCommand (LocalPath, string.Format ("commit --all --message=\"{0}\" --author=\"{1} <{2}>\"",
	            "Changes by SparkleShare", user_name, user_email));
            }

            git_commit.StartAndReadStandardOutput ();
            File.Delete (message_file_path);
        }


        // Merges the fetched changes
        bool Merge ()
        {
            string message = FormatCommitMessage ();

            if (message != null) {
                Add ();
                Commit (message);
            }

            GitCommand git;

            // Stop if we're already in a merge because something went wrong
            if (this.in_merge) {
                 git = new GitCommand (LocalPath, "merge --abort");
                 git.StartAndWaitForExit ();

                 return false;
            }

            // Temporarily change the ignorecase setting to true to avoid
            // conflicts in file names due to letter case changes
            git = new GitCommand (LocalPath, "config core.ignorecase true");
            git.StartAndWaitForExit ();

            git = new GitCommand (LocalPath, "merge --no-edit FETCH_HEAD");
            git.StartInfo.RedirectStandardOutput = false;

            string error_output = git.StartAndReadStandardError ();

            if (git.ExitCode != 0) {
                // Stop when we can't merge due to locked local files
                // error: cannot stat 'filename': Permission denied
                if (error_output.Contains ("error: cannot stat")) {
                    Error = ErrorStatus.UnreadableFiles;
                    Logger.LogInfo ("Git", Name + " | Error status changed to " + Error);

                    git = new GitCommand (LocalPath, "merge --abort");
                    git.StartAndWaitForExit ();

                    git = new GitCommand (LocalPath, "config core.ignorecase false");
                    git.StartAndWaitForExit ();

                    return false;

                } else {
                    Logger.LogInfo ("Git", error_output);
                    Logger.LogInfo ("Git", Name + " | Conflict detected, trying to get out...");

                    while (this.in_merge && HasLocalChanges) {
                        try {
                            ResolveConflict ();

                        } catch (Exception e) {
                            Logger.LogInfo ("Git", Name + " | Failed to resolve conflict, trying again...", e);
                        }
                    }

                    Logger.LogInfo ("Git", Name + " | Conflict resolved");
                }
            }

            git = new GitCommand (LocalPath, "config core.ignorecase false");
            git.StartAndWaitForExit ();

            return true;
        }


        void ResolveConflict ()
        {
            // This is a list of conflict status codes that Git uses, their
            // meaning, and how SparkleShare should handle them.
            //
            // DD    unmerged, both deleted    -> Do nothing
            // AU    unmerged, added by us     -> Use server's, save ours as a timestamped copy
            // UD    unmerged, deleted by them -> Use ours
            // UA    unmerged, added by them   -> Use server's, save ours as a timestamped copy
            // DU    unmerged, deleted by us   -> Use server's
            // AA    unmerged, both added      -> Use server's, save ours as a timestamped copy
            // UU    unmerged, both modified   -> Use server's, save ours as a timestamped copy
            // ??    unmerged, new files       -> Stage the new files

            var git_status = new GitCommand (LocalPath, "status --porcelain");
            string output = git_status.StartAndReadStandardOutput ();

            string [] lines = output.Split ("\n".ToCharArray ());
            bool trigger_conflict_event = false;

            foreach (string line in lines) {
                string conflicting_file_path = line.Substring (3);
                conflicting_file_path = conflicting_file_path.Trim ("\"".ToCharArray ());

                // Remove possible rename indicators
                string [] separators = {" -> \"", " -> "};
                foreach (string separator in separators) {
                    if (conflicting_file_path.Contains (separator))
                        conflicting_file_path = conflicting_file_path.Substring (conflicting_file_path.IndexOf (separator) + separator.Length);
                }

                Logger.LogInfo ("Git", Name + " | Conflict type: " + line);

                // Ignore conflicts in hidden files and use the local versions
                if (conflicting_file_path.EndsWith (".sparkleshare") || conflicting_file_path.EndsWith (".empty")) {
                    Logger.LogInfo ("Git", Name + " | Ignoring conflict in special file: " + conflicting_file_path);

                    // Recover local version
                    var git_ours = new GitCommand (LocalPath, "checkout --ours \"" + conflicting_file_path + "\"");
                    git_ours.StartAndWaitForExit ();

                    string abs_conflicting_path = Path.Combine (LocalPath, conflicting_file_path);

                    if (File.Exists (abs_conflicting_path))
                        File.SetAttributes (abs_conflicting_path, FileAttributes.Hidden);

                    continue;
                }

                Logger.LogInfo ("Git", Name + " | Resolving: " + conflicting_file_path);

                // Both the local and server version have been modified
                if (line.StartsWith ("UU") || line.StartsWith ("AA") ||
                    line.StartsWith ("AU") || line.StartsWith ("UA")) {

                    // Get the author name of the conflicting version
                    var git_log = new GitCommand (LocalPath, "log -n 1 FETCH_HEAD --pretty=format:%an " + conflicting_file_path);
                    string other_author_name = git_log.StartAndReadStandardOutput ();


                    // Generate distinguishing names for both versions of the file
                    string clue_A = string.Format (" (by {0})", base.local_config.User.Name);
                    string clue_B = string.Format (" (by {0})", other_author_name);

                    if (base.local_config.User.Name == other_author_name) {
                        clue_A = " (A)";
                        clue_B = " (B)";
                    }


                    string file_name_A = Path.GetFileNameWithoutExtension (conflicting_file_path) + clue_A + Path.GetExtension (conflicting_file_path);
                    string file_name_B = Path.GetFileNameWithoutExtension (conflicting_file_path) + clue_B + Path.GetExtension (conflicting_file_path);

                    string abs_conflicting_file_path = Path.Combine (LocalPath, conflicting_file_path);

                    string abs_file_path_A = Path.Combine (Path.GetDirectoryName (abs_conflicting_file_path), file_name_A);
                    string abs_file_path_B = Path.Combine (Path.GetDirectoryName (abs_conflicting_file_path), file_name_B);


                    // Recover local version
                    var git_checkout_A = new GitCommand (LocalPath, "checkout --ours \"" + conflicting_file_path + "\"");
                    git_checkout_A.StartAndWaitForExit ();

                    if (File.Exists (abs_conflicting_file_path) && !File.Exists (abs_file_path_A))
                        File.Move (abs_conflicting_file_path, abs_file_path_A);


                    // Recover server version
                    var git_checkout_B = new GitCommand (LocalPath, "checkout --theirs \"" + conflicting_file_path + "\"");
                    git_checkout_B.StartAndWaitForExit ();

                    if (File.Exists (abs_conflicting_file_path) && !File.Exists (abs_file_path_B))
                        File.Move (abs_conflicting_file_path, abs_file_path_B);


                    // Recover original (before both versions diverged)
                    var git_checkout = new GitCommand (LocalPath, "checkout ORIG_HEAD^ \"" + conflicting_file_path + "\"");
                    git_checkout.StartAndWaitForExit ();


                    trigger_conflict_event = true;


                // The server version has been modified, but the local version was removed
                } else if (line.StartsWith ("DU")) {

                    // The modified local version is already in the checkout, so it just needs to be added.
                    // We need to specifically mention the file, so we can't reuse the Add () method
                    var git_add = new GitCommand (LocalPath, "add \"" + conflicting_file_path + "\"");
                    git_add.StartAndWaitForExit ();


                // The local version has been modified, but the server version was removed
                } else if (line.StartsWith ("UD")) {

                    // Recover our version
                    var git_theirs = new GitCommand (LocalPath, "checkout --ours \"" + conflicting_file_path + "\"");
                    git_theirs.StartAndWaitForExit ();


                // Server and local versions were removed
                } else if (line.StartsWith ("DD")) {
                    Logger.LogInfo ("Git", Name + " | No need to resolve: " + line);

                // New local files
                } else if (line.StartsWith ("??")) {
                    Logger.LogInfo ("Git", Name + " | Found new file, no need to resolve: " + line);

                } else {
                    Logger.LogInfo ("Git", Name + " | Don't know what to do with: " + line);
                }
            }

            Add ();

            var git = new GitCommand (LocalPath,
                "commit --message=\"Conflict resolution\" --author=\"SparkleShare <info@sparkleshare.org>\"");

            git.StartInfo.RedirectStandardOutput = false;
            git.StartAndWaitForExit ();

            HasUnsyncedChanges = true;

            if (trigger_conflict_event)
                OnConflictResolved ();
        }


        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 ("Git", Name + " | Restoring \"" + path + "\" (revision " + revision + ")");

            // Restore the older file...
            var git = new GitCommand (LocalPath, "checkout " + revision + " \"" + path + "\"");
            git.StartAndWaitForExit ();

            string local_file_path = Path.Combine (LocalPath, path);

            // ...move it...
            try {
                File.Move (local_file_path, target_file_path);

            } catch (Exception e) {
                string message = string.Format ("Failed to move \"{0}\" to \"{1}\"", local_file_path, target_file_path);
                Logger.LogInfo ("Git", Name + " | " + message, e);
            }

            // ...and restore the most recent revision
            git = new GitCommand (LocalPath, "checkout " + CurrentRevision + " \"" + path + "\"");
            git.StartAndWaitForExit ();

            if (target_file_path.StartsWith (LocalPath))
                new Thread (() => OnFileActivity (null)).Start ();
        }


        public override List<Change> UnsyncedChanges {
          get {
              return ParseStatus ();
            }
        }


        public override List<ChangeSet> GetChangeSets ()
        {
            return GetChangeSetsInternal (null);
        }

        public override List<ChangeSet> GetChangeSets (string path)
        {
            return GetChangeSetsInternal (path);
        }

        List<ChangeSet> GetChangeSetsInternal (string path)
        {
            var change_sets = new List <ChangeSet> ();
            GitCommand git;

            string log_args = "--since=1.month --name-status --date=iso --find-renames --no-merges --no-color";

            if (path == null) {
                git = new GitCommand (LocalPath, "--no-pager log " + log_args);

            } else {
                path = path.Replace ("\\", "/");
                git = new GitCommand (LocalPath, "--no-pager log " + log_args + " -- \"" + path + "\"");
            }

            string output = git.StartAndReadStandardOutput ();

            if (path == null && string.IsNullOrWhiteSpace (output)) {
                git = new GitCommand (LocalPath, "--no-pager log -n 75 " + log_args);
                output = git.StartAndReadStandardOutput ();
            }


            // Offset the output so our log_regex can be simpler
            string commit_sep = "commit ";

            if (output.StartsWith (commit_sep))
                output = output.Substring (commit_sep.Length) + "\n\n" + commit_sep;


			MatchCollection matches = this.log_regex.Matches (output);

            foreach (Match match in matches) {
                ChangeSet change_set = ParseChangeSet (match);

                if (change_set == null)
                    continue;

                int count = 0;
                foreach (string line in match.Groups ["files"].Value.Split ("\n".ToCharArray ())) {
                    if (count++ == 256)
                        break;

                    Change change = ParseChange (line);

                    if (change == null)
                        continue;

                    change.Timestamp = change_set.Timestamp;
                    change_set.Changes.Add (change);
                }


                if (path == null && change_sets.Count > 0) {
                    ChangeSet last_change_set = change_sets [change_sets.Count - 1];

					// If a change set set already exists for this user and day, group into that one
                    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 if (path != null) {
                    // Don't show removals or moves in the history list of a file
                    var changes = new Change [change_set.Changes.Count];
                    change_set.Changes.CopyTo (changes);

                    foreach (Change change in changes) {
                        if (!change.Path.Equals (path))
                            continue;

                        if (change.Type == ChangeType.Deleted || change.Type == ChangeType.Moved)
                            change_set.Changes.Remove (change);
                    }

                    change_sets.Add (change_set);

                } else {
                    change_sets.Add (change_set);
                }
            }

            return change_sets;
        }


        ChangeSet ParseChangeSet (Match match)
        {
            ChangeSet change_set = new ChangeSet ();

            // Set the name and email
            if (match.Groups ["name"].Value == "SparkleShare")
                return null;

            change_set.Folder = new SparkleFolder (Name);
            change_set.Revision = match.Groups ["commit"].Value;
            change_set.User = new User (match.Groups ["name"].Value, match.Groups ["email"].Value);
            change_set.RemoteUrl = RemoteUrl;

            if (StorageType == StorageType.Encrypted) {
                string password_file_path = Path.Combine (LocalPath, ".git", "info", "encryption_password");
                string password = File.ReadAllText (password_file_path);

                try {
                    change_set.User = new User (
                        change_set.User.Name.AESDecrypt (password),
                        change_set.User.Email.AESDecrypt (password));

                } catch (Exception) {
                    change_set.User = new User (match.Groups ["name"].Value, match.Groups ["email"].Value);
                }
            }

            // Get the right date and time by offsetting the timezones
            change_set.Timestamp = new DateTime (
                int.Parse (match.Groups ["year"].Value), int.Parse (match.Groups ["month"].Value), int.Parse (match.Groups ["day"].Value),
                int.Parse (match.Groups ["hour"].Value), int.Parse (match.Groups ["minute"].Value), int.Parse (match.Groups ["second"].Value));

            string time_zone = match.Groups ["timezone"].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);

            return change_set;
        }


        Change ParseChange (string line)
        {
            // Skip lines containing backspace characters or the .sparkleshare file
            if (line.Contains ("\\177") || line.Contains (".sparkleshare"))
                return null;

            // File lines start with a change type letter and then a tab character
            if (!line.StartsWith ("A\t") &&
                !line.StartsWith ("M\t") &&
                !line.StartsWith ("D\t") &&
                !line.StartsWith ("R100\t")) {

                return null;
            }

            Change change = new Change () { Type = ChangeType.Added };
            string file_path;

            int first_tab_pos = line.IndexOf ('\t');
            int last_tab_pos = line.LastIndexOf ('\t');

            if (first_tab_pos == last_tab_pos) {
                char type_letter = line [0];

                if (type_letter == 'M')
                    change.Type = ChangeType.Edited;

                if (type_letter == 'D')
                    change.Type = ChangeType.Deleted;

                file_path = line.Substring (first_tab_pos + 1);

            } else {
                change.Type = ChangeType.Moved;

                // The "to" and "from" file paths are separated by a tab
                string [] parts = line.Split ("\t".ToCharArray ());

                file_path = parts [1];
                string to_file_path = parts [2];

                to_file_path = to_file_path.Replace ("\\\"", "\"");
                change.MovedToPath = to_file_path;
            }

            file_path = file_path.Replace ("\\\"", "\"");
            change.Path = file_path;

            string empty_name = ".empty";

            // Handle .empty files as if they were folders
            if (change.Path.EndsWith (empty_name)) {
                change.Path = change.Path.Substring (0, change.Path.Length - empty_name.Length);

                if (change.Type == ChangeType.Moved)
                    change.MovedToPath = change.MovedToPath.Substring (0, change.MovedToPath.Length - empty_name.Length);

                change.IsFolder = true;
            }

            return change;
        }


        // The pre-push hook may have been changed by Git LFS, overwrite it to use our own configuration
        void PrepareGitLFS ()
        {
            string pre_push_hook_path = Path.Combine (LocalPath, ".git", "hooks", "pre-push");
            string pre_push_hook_content;

            if (InstallationInfo.OperatingSystem == OS.macOS || InstallationInfo.OperatingSystem == OS.Windows) {
                pre_push_hook_content =
                    "#!/bin/sh" + Environment.NewLine +
                    "env GIT_SSH_COMMAND='" + GitCommand.FormatGitSSHCommand (auth_info) + "' " +
                    Path.Combine (Configuration.DefaultConfiguration.BinPath, "git-lfs").Replace ("\\", "/")  + " pre-push \"$@\"";

            } else {
                pre_push_hook_content =
                    "#!/bin/sh" + Environment.NewLine +
                    "env GIT_SSH_COMMAND='" + GitCommand.FormatGitSSHCommand (auth_info) + "' " +
                    "git-lfs pre-push \"$@\"";
            }

            if (InstallationInfo.OperatingSystem != OS.Windows) {
                // TODO: Use proper API
                var chmod = new Command ("chmod", "700 " + pre_push_hook_path);
                chmod.StartAndWaitForExit ();
            }

            Directory.CreateDirectory (Path.GetDirectoryName (pre_push_hook_path));
            File.WriteAllText (pre_push_hook_path, pre_push_hook_content);
        }


        // 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 (".git")) {
                        if (child_path.Equals (Path.Combine (LocalPath, ".git")))
                            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 ("Git", 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 (Exception e) {
                            Logger.LogInfo ("Git", Name + " | Failed adding empty folder " + path, e);
                        }
                    }
                }

            } catch (IOException e) {
                Logger.LogInfo ("Git", "Failed preparing directory", e);
            }
        }



        List<Change> ParseStatus ()
        {
            List<Change> changes = new List<Change> ();

            var git_status = new GitCommand (LocalPath, "status --porcelain");
            git_status.Start ();

            while (!git_status.StandardOutput.EndOfStream) {
                string line = git_status.StandardOutput.ReadLine ();
                line = line.Trim ();

                if (line.EndsWith (".empty") || line.EndsWith (".empty\""))
                    line = line.Replace (".empty", "");

                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 = path,
                        MovedToPath = moved_to_path
                    };

                } else {
                    string path = line.Substring (2).Trim ("\" ".ToCharArray ());
                    change = new Change () { Path = 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);
            }

            git_status.StandardOutput.ReadToEnd ();
            git_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 +=  "< ‘" + change.Path + "’\n";
                    message +=  "> ‘" + 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 (".git") ||
                        directory.Name.Equals ("rebase-apply")) {

                        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: (?'name'.+?) <(?'email'.+?)>\n" +
            "Date:   (?'year'[0-9]{4})-(?'month'[0-9]{2})-(?'day'[0-9]{2}) (?'hour'[0-9]{2}):(?'minute'[0-9]{2}):(?'second'[0-9]{2}) (?'timezone'.[0-9]{4})\n" +
            "\n" +
            "    (?'message'.+?)\n" +
            "\n" +
            "(?'files'.+?)\n\ncommit ", RegexOptions.Singleline | RegexOptions.Compiled);
    }
}
