#!/usr/bin/lua5.1
-- -*- Lua -*-
-- gitano-update-hook
--
-- Git (with) Augmented network operations -- Update hook handler
--
-- Copyright 2012-2017 Daniel Silverstone <dsilvers@digital-scurf.org>
-- All rights reserved.
--
-- Redistribution and use in source and binary forms, with or without
-- modification, are permitted provided that the following conditions
-- are met:
-- 1. Redistributions of source code must retain the above copyright
--    notice, this list of conditions and the following disclaimer.
-- 2. Redistributions in binary form must reproduce the above copyright
--    notice, this list of conditions and the following disclaimer in the
--    documentation and/or other materials provided with the distribution.
-- 3. Neither the name of the author nor the names of their contributors
--    may be used to endorse or promote products derived from this software
--    without specific prior written permission.
--
-- THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND
-- ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-- IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-- ARE DISCLAIMED.  IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE
-- FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
-- DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
-- OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
-- HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
-- LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
-- OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
-- SUCH DAMAGE.
--
--

-- Gitano modules installed into /usr/share/lua/5.1/?.lua

local gitano = require "gitano"
local gall = require "gall"
local luxio = require "luxio"
local sio = require "luxio.simple"
local sp = require "luxio.subprocess"

gitano.config.lib_bin_path("/usr/lib/gitano/bin")
gitano.config.share_path("/usr/share/gitano")
gitano.i18n.set_langpack_path("/usr/share/gitano/lang") gitano.i18n.set_category()
gitano.plugins.load_plugins {"/etc/gitano/plugins", "/usr/lib/gitano/plugins"}

local refname, oldsha, newsha = ...

local start_log_level = gitano.log.get_level()
-- Clamp level at info until we have checked if the caller
-- is an admin or not
gitano.log.cap_level(gitano.log.level.INFO)
gitano.log.syslog.open()

local nullsha = ("0"):rep(40)

local repo_root = luxio.getenv("GITANO_ROOT")
local username  = luxio.getenv("GITANO_USER") or "gitano/anonymous"
local keytag    = luxio.getenv("GITANO_KEYTAG") or "unknown"
local project   = luxio.getenv("GITANO_PROJECT") or ""
local source    = luxio.getenv("GITANO_SOURCE") or "ssh"
local running   = luxio.getenv("GITANO_RUNNING")

-- Check whether we are called through gitano-auth
if not running then
   return 0
end

-- Now load the administration data
gitano.config.repo_path(repo_root)
local admin_repo = gall.repository.new((repo_root or "") .. "/gitano-admin.git")

if not admin_repo then
   gitano.log.fatal(gitano.i18n.expand("ERROR_NO_ADMIN_REPO"));
end

local admin_head = admin_repo:get(admin_repo.HEAD)

if not admin_head then
   gitano.log.fatal(gitano.i18n.expand("ERROR_BAD_ADMIN_REPO"));
end

local config, msg = gitano.config.parse(admin_head)

if not config then
   gitano.log.critical(gitano.i18n.expand("ERROR_CANNOT_PARSE_ADMIN"))
   gitano.log.critical("  * " .. (msg or "No error?"))
   gitano.log.fatal(gitano.i18n.expand("ERROR_CANNOT_CONTINUE"))
end

-- Now, are we an admin?
if config.groups["gitano-admin"].filtered_members[username] then
   -- Yep, so blithely reset logging level
   gitano.log.set_level(start_log_level)
end

if not config.global.silent then
   -- Not silent, bump to chatty level automatically
   gitano.log.bump_level(gitano.log.level.CHAT)
end

local repo, msg = gitano.repository.find(config, project)
if not repo then
   gitano.log.critical(gitano.i18n.expand("ERROR_CANNOT_LOCATE_REPO"))
   gitano.log.critical("  * " .. (tostring(msg)))
   gitano.log.fatal(gitano.i18n.expand("ERROR_CANNOT_CONTINUE"))
end

if repo.is_nascent then
   gitano.log.fatal(gitano.i18n.expand("ERROR_REPO_IS_NASCENT", {name=repo.name}))
end


-- Prepare an update operation

local context = {
   ["source"] = source,
   ["ref"] = refname,
   ["oldsha"] = oldsha,
   ["newsha"] = newsha,
   ["user"] = username,
}

-- Attempt to work out what's going on regarding the update.

local action = "**UNKNOWN**"

if oldsha == nullsha and newsha ~= nullsha then
   context["operation"] = "createref"
   action = "creation"
elseif oldsha ~= nullsha and newsha == nullsha then
   context["operation"] = "deleteref"
   action = "deletion"
else
   local base, msg = repo.git:merge_base(oldsha, newsha)
   if not base then
      gitano.log.fatal(msg)
   elseif (base == true) or ((base) and (base == newsha)) then
      context["operation"] = "updaterefnonff"
      action = "non-ff update"
   else
      context["operation"] = "updaterefff"
      action = "update"
   end
end

-- Populate the trees

local function do_expensive_populate_context(context)

   local oldtree, newtree
   if oldsha == nullsha or newsha == nullsha then
      repo.git:force_empty_tree()
   end

   if oldsha == nullsha then
      oldtree = repo.git:get(gall.tree.empty_sha).content
   else
      local thing = repo.git:get(oldsha)
      while thing.type == "tag" do
         thing = thing.content.object
      end
      if thing.type == "commit" then
         oldtree = thing.content.tree.content
      else
         oldtree = repo.git:get(gall.tree.empty_sha).content
         gitano.log.warn(gitano.i18n.expand("ODD_OLD_OBJECT_NOT_COMMIT_OR_TAG",
                                            {sha=oldsha}))
      end
   end

   if newsha == nullsha then
      newtree = repo.git:get(gall.tree.empty_sha).content
   else
      local thing = repo.git:get(newsha)
      while thing.type == "tag" do
         thing = thing.content.object
      end
      if thing.type == "commit" then
         newtree = thing.content.tree.content
      else
         newtree = repo.git:get(gall.tree.empty_sha).content
         gitano.log.warn(gitano.i18n.expand("ODD_NEW_OBJECT_NOT_COMMIT_OR_TAG",
                                            {sha=newsha}))
      end
   end

   -- First, populate gitano/starttree and gitano/targettree

   local function set_list(tag, entries)
      -- Make the set for direct string tests
      for i = 1, #entries do
         entries[entries[i]] = true
      end
      context[tag] = entries
   end

   local function populate_tree(tag, tree)
      local flat_tree = gall.tree.flatten(tree)
      local names = {}
      for fn in pairs(flat_tree) do
         names[#names+1] = fn
      end
      set_list(tag, names)
   end

   populate_tree("start_tree", oldtree)
   populate_tree("target_tree", newtree)

   -- Now gitano/treedelta
   local delta = oldtree:diff_to(newtree)
   local targets, added, deleted, modified, renamed, renamedto =
      {}, {}, {}, {}, {}, {}

   for i = 1, #delta do
      local details = delta[i]
      local fname = details.filename
      targets[#targets+1] = fname
      if details.action == "A" then
         added[#added+1] = fname
      end
      if details.action == "C" and details.score == "100" then
         added[#added+1] = fname
      end
      if details.action == "D" then
         deleted[#deleted+1] = fname
      end
      if details.action == "M" or
         ((details.action == "R" or details.action == "C") and
            (tonumber(details.score) < 100)) then
            modified[#modified+1] = fname
      end
      if details.action == "R" then
         renamed[#renamed+1] = details.src_name
         renamedto[#renamedto+1] = fname
      end

      context["treediff/kind/" .. fname] = details.endkind
      context["treediff/oldkind/" .. fname] = details.startkind
   end

   set_list("treediff/targets", targets)
   set_list("treediff/added", added)
   set_list("treediff/deleted", deleted)
   set_list("treediff/modified", modified)
   set_list("treediff/renamed", renamed)
   set_list("treediff/renamedto", renamedto)
end

local function defer_generation(key)
   context[key] = function(ctx)
      -- This populates quite a bit, so we do this
      -- test in case someone else got hold of this
      -- beforehand and manages to cross the streams
      if type(ctx[key]) == "function" then
         gitano.log.chat(gitano.i18n.expand("GENERATING_TREEDELTAS",
                                            {key=key}))
         do_expensive_populate_context(ctx)
         gitano.log.chat(gitano.i18n.expand("GENERATED_TREEDELTAS"))
      end
      -- And return what we were meant to be
      return ctx[key]
   end
end

defer_generation "start_tree"
defer_generation "target_tree"
defer_generation "treediff/targets"
defer_generation "treediff/added"
defer_generation "treediff/deleted"
defer_generation "treediff/modified"
defer_generation "treediff/renamed"
defer_generation "treediff/renamedto"

-- Fill out source and target object types
local function populate(sha, pfx)
   if sha == ("0"):rep(40) then
      context[pfx.."type"] = "empty"
   else
      local obj = repo.git:get(sha)
      if not obj then
         context[pfx.."type"] = "unknown"
      else
         context[pfx.."type"] = obj.type
         if obj.type == 'tag' then
            obj = obj.content.object
            if not obj then
               context[pfx.."taggedtype"] = "unknown"
            else
               context[pfx.."taggedtype"] = obj.type
               context[pfx.."taggedsha"] = obj.sha
            end
         end
         if obj.content and obj.content.signature and obj.content.signature ~= "" then
            context[pfx.."signed"] = "yes"
         end
      end
   end
end
populate(context.oldsha, "old")
populate(context.newsha, "new")

-- Run the ruleset given the context

local action, reason = repo:run_lace(context)

if not action then
   gitano.log.crit(reason)
   gitano.log.fatal(gitano.i18n.expand("ERROR_RULESET_UNCLEAN_FINISH"))
end

if action ~= "allow" then
   gitano.log.critical(gitano.i18n.expand("ERROR_RULES_REFUSED_UPDATE",
                                          {reason=reason}))
   gitano.log.fatal(gitano.i18n.expand("ERROR_RULESET_DENIED_ACTION"))
end

-- Now perform any special hook checks (e.g. for the admin hook)
gitano.log.ddebug("Ruleset allowed the action, let's run builtin action")

local allow, msg = gitano.actions.update_actions(conf, repo, context)
if not allow then
   gitano.log.critical(gitano.i18n.expand("ERROR_BUILTIN_HANDLERS_SAID", {msg=msg}))
   gitano.log.fatal(gitano.i18n.expand("ERROR_ACTIONS_REFUSED_ACTION"))
end

if repo:uses_hook("update") then
   gitano.log.debug("Configuring for update hook")
   gitano.actions.set_supple_globals("update")

   local msg = gitano.i18n.expand("RUNNING_UPDATE_HOOK")
   gitano.log.info(msg)
   gitano.log.syslog.info(msg)

   local info = {
      username = username,
      keytag = keytag,
      source = source,
      realname = (config.users[username] or {}).real_name or "",
      email = (config.users[username] or {}).email_address or "",
   }
   local ok, msg = gitano.supple.run_hook("update", repo, info,
                                          refname, oldsha, newsha)
   if not ok then
      gitano.log.fatal(msg or gitano.i18n.expand("ERROR_NO_ERROR_FOUND"))
   end
   gitano.log.info(gitano.i18n.expand("FINISHED"))
end

gitano.log.info(gitano.i18n.expand("ALLOWING_UPDATE",
                                   {ref=refname, old=oldsha, new=newsha}))

gitano.log.syslog.info("Allowing ref", action, "of", refname,
                       "( was", oldsha, "is now", newsha, ")")

gitano.log.syslog.close()

return 0
