-- gall.repository
--
-- Git Abstraction Layer for Lua -- Repository interface
--
-- Copyright 2012 Daniel Silverstone <dsilvers@digital-scurf.org>
--
--

---
-- Repository interface
--
-- @module gall.repository

local ll = require "gall.ll"
local object = require "gall.object"
local tree = require "gall.tree"

local luxio = require "luxio"

local chomp = ll.chomp

local repomethod = {}

local pattern = {
   fullsha = string.rep("[0-9a-f]", 40),
}

for k, v in pairs(pattern) do
   pattern[k] = ("^%s$"):format(v)
end

local function _repotostring(repo)
   return "<GitRepository(" .. repo.path .. ")>"
end

---
-- Abstracted representation of a Git repository.
--
-- A repository instance represents a Git repository on disk.  It is the
-- interface through which all interaction with a Git repository begins.
--
-- @type repository


function repomethod:_run_with_input_and_env(env, input, want_output, ...)
   local t = {...}
   t.repo = self.path
   if want_output then
      t.stdout = want_output
   end
   if input then
      t.stdin = input
   end
   -- We never want to see the stderr dumped to the client, so we eat it.
   t.stderr = true
   if env then
      t.env = env
   end
   return ll.rungit(t)
end

function repomethod:_run_with_input(input, want_output, ...)
   return self:_run_with_input_and_env(nil, input, want_output, ...)
end

function repomethod:_run(want_output, ...)
   return self:_run_with_input(nil, want_output, ...)
end

---
-- Run a git command across a repository and gather its stdout
--
-- This will run the supplied git command within the repository and will
-- return the stdout of the run, with the final newline removed.
--
-- For return values, see @{gall.ll.rungit}
--
-- @function gather
-- @tparam string command The git command name
-- @tparam string ... The arguments to the git command
-- @treturn ... As per @{gall.ll.rungit}
-- @see gall.ll.rungit
-- @see repository:rawgather

function repomethod:gather(...)
   return self:_run(chomp, ...)
end

---
-- Run a git command across a repository and gather its stdout raw.
--
-- This will run the supplied git command within the repository and will
-- return the stdout of the run without processing it at all.
--
-- For return values, see @{gall.ll.rungit}
--
-- @function rawgather
-- @tparam string command The git command name
-- @tparam string ... The arguments to the git command
-- @treturn ... As per @{gall.ll.rungit}
-- @see gall.ll.rungit
-- @see repository:gather

function repomethod:rawgather(...)
   return self:_run(true, ...)
end

---
-- Force the empty tree object to exist and return it.
--
-- @function force_empty_tree
-- @treturn object The git object representing the empty tree

function repomethod:force_empty_tree()
   self:_run(true, "hash-object", "-t", "tree", "-w", "/dev/null")
   return self:get(tree.empty_sha)
end

---
-- Calculate the hash of the given object, optionally injecting it into
-- the repository store.
--
-- @function hash_object
-- @tparam string type The type of the object
-- @tparam string content The raw content of the object
-- @tparam boolean inject Whether or not to inject the object into the store.
-- @treturn[1] string The OID of the given object
-- @treturn[2] nil Nil on error.

function repomethod:hash_object(type, content, inject)
   local args = {
      "hash-object", "-t", type, "--stdin",
   }
   if inject then
      args[#args+1] = "-w"
   end
   local ok, sha = self:_run_with_input(content, chomp, unpack(args))
   return (ok == 0) and sha or nil
end

---
-- Get the OID referent of the given reference.
--
-- @function get_ref
-- @tparam string ref The reference name
-- @treturn[1] string The OID referent
-- @treturn[2] nil Nil on error

function repomethod:get_ref(ref)
   local ok, sha = self:_run(chomp, "show-ref", "--verify", "-s", ref)
   return (ok == 0) and sha or nil
end

if ll.git2 then
   function repomethod:get_ref(ref)
      return ll.git2.lookup_sha_from_ref(self.git2.repo, ref)
   end
end

---
-- Update the given reference.
--
-- If new_ref is nil then the reference is to be deleted.
-- If reason is nil, then `"Gall internal operations"` will be used.
-- If old_ref is nil then the reference must not exist before calling this.
--
-- @function update_ref
-- @tparam string ref The reference to update
-- @tparam ?string new_ref The OID to update the reference to point at
-- @tparam ?string reason The reason for updating the reference (for reflog)
-- @tparam ?string old_ref The old OID (for preventing race conditions)

function repomethod:update_ref(ref, new_ref, reason, old_ref)
   if new_ref and not old_ref then
      old_ref = string.rep("0", 40)
   end
   if not reason then
      reason = "Gall internal operations"
   end

   local cmd = { "update-ref", "-m", reason }

   if not new_ref then
      cmd[#cmd+1] = "-d"
   end

   cmd[#cmd+1] = ref

   if new_ref then
      cmd[#cmd+1] = new_ref
   end
   if old_ref then
      cmd[#cmd+1] = old_ref
   end
   
   local why = self:_run(false, unpack(cmd))
   if why ~= 0 then
      return nil, "update-ref returned " .. tostring(why)
   end
   return true
end

---
-- Ensure the HTTP server info is updated in the repository.
--
-- This essentially just calls `git update-server-info`
--
-- @function update_server_info
-- @treturn[1] boolean True on success
-- @treturn[2] nil Nil on error
-- @treturn[2] string The error message

function repomethod:update_server_info()
   local why = self:_run(false, "update-server-info")
   if why ~= 0 then
      return nil, "update-server-info returned " .. tostring(why)
   end
   return true
end

---
-- Return all (non-symbolic) references in the repository.
--
-- The returned table is a map from reference name to the SHA1 referent.
--
-- @function all_refs
-- @treturn[1] table The references in the repository
-- @treturn[2] nil Nil on error
-- @treturn[2] string The error message

function repomethod:all_refs()
   local ok, refs = self:_run(chomp, "show-ref")
   if ok ~= 0 then return nil, refs end
   local reft = {}
   for sha, ref in refs:gmatch("([0-9a-f]+) (refs/[^\n]+)") do
      reft[ref] = sha
   end
   return reft
end

---
-- Normalise a given _SHAish_ to a true OID.
--
-- @function normalise
-- @tparam string sha The _SHAish_ to normalise.
-- @treturn[1] string The full OID referred to by _SHAish_
-- @treturn[2] nil Nil on error
-- @treturn[2] string The error message.

function repomethod:normalise(sha)
   -- Purpose is to take a 'shaish' object and normalise it
   if sha:match(pattern.fullsha) then
      return sha
   else
      local fullsha
      if ll.git2 then
	 local refobj = ll.git2.revparse_single(self.git2.repo, sha)
	 if refobj then
	    fullsha = refobj
	 end
      end
      if not fullsha then
	 local ok, out, err = self:_run_with_input(sha, chomp, "cat-file", "--batch-check")
	 if ok ~= 0 then
	    error((out or "") .. "\n" .. (err or ""))
	 end
	 fullsha = out:match("^("..string.rep("[0-9a-f]", 40)..")")
      end
      if fullsha then
	 return fullsha
      end
   end
   return nil, "Unable to normalise " .. tostring(sha)
end

---
-- Retrieve an object from the repository.
--
-- @function get
-- @tparam string _sha The _SHAish_ to retrieve.
-- @treturn[1] object The low level object representing the given _SHAish_.
-- @treturn[2] nil Nil on error
-- @treturn[2] string The error message

function repomethod:get(_sha)
   local sha, err = self:normalise(_sha)
   if not sha then
      return nil, err
   end
   local ret = self.objmemo[sha]
   if not ret then
      ret = object.new(self, sha)
      self.objmemo[sha] = ret
   end
   return ret
end

---
-- Calculate the merge base(s) for the given sha pair
--
-- @function merge_base
-- @tparam string sha_1 The left-side OID for the merge
-- @tparam string sha_2 The right-side OID for the merge
-- @tparam boolean get_all Whether to retrieve all possible merge bases.
-- @treturn[1] boolean True if there simply isn't a merge base to be found
-- @treturn[2] string... The OID(s) of the merge base(s)
-- @treturn[3] nil Nil on error
-- @treturn[3] string The error message

function repomethod:merge_base(sha_1, sha_2, get_all)
   local args = { sha_1, sha_2 }
   if get_all then
      args = { "-a", sha_1, sha_2 }
   end
   args.repo = self.path
   args.stderr = true
   local ok, out, err = ll.merge_base(args)
   if ok ~= 0 and ok ~= 1 then
      return nil, (out or "") .. "\n" .. (err or "")
   end
   local ret = {}
   for sha in out:gmatch("([a-f0-9]+)") do
      ret[#ret+1] = sha
   end
   if #ret == 0 then
      return true
   end
   return unpack(ret)
end

if ll.git2 then
   local old_merge_base = repomethod.merge_base
   function repomethod:merge_base(sha_1, sha_2, get_all)
      if get_all then
	 return old_merge_base(self, sha_1, sha_2, get_all)
      end
      local commitish_1 = self:get(sha_1)
      local commitish_2 = self:get(sha_2)
      pcall(function()
	       if commitish_1.type == "tag" then
		  commitish_1 = commitish_1.content.object
	       end
	       if commitish_2.type == "tag" then
		  commitish_2 = commitish_2.content.object
	       end
	    end)
      local oid_base, err = ll.git2.merge_base(self.git2.repo,
					       commitish_1.sha,
					       commitish_2.sha)
      if not oid_base then
	 if tostring(err) == "ENOTFOUND" then
	    return true
	 end
	 return nil, err
      end
      return oid_base, err
   end
end

---
-- Calculate the revision list shared between the given tips.
--
-- @function rev_list
-- @tparam string oldhead The "old" tip of the commit tree.
-- @tparam string newhead The "new" tip of the commit tree.
-- @tparam boolean firstonly Whether to return only the first shared revision
-- @treturn[1] boolean True if there are no shared commits
-- @treturn[2] string... The OIDs of the shared revision(s)
-- @treturn[3] nil Nil on error
-- @treturn[3] string The error message

function repomethod:rev_list(oldhead, newhead, firstonly)
   local args = { newhead, "^" .. oldhead }
   if firstonly then
      table.insert(args, 1, "--first-parent")
   end
   args.repo = self.path
   args.stderr = true
   local ok, out = ll.rev_list(args)
   if ok ~= 0 then
      return nil, out
   end
   local ret = {}
   for sha in out:gmatch("([a-f0-9]+)") do
      ret[#ret+1] = sha
   end
   if #ret == 0 then
      return true
   end
   return ret
end

---
-- Get or set the given symbolic reference.
--
-- @function symbolic_ref
-- @tparam string name The reference to get or set
-- @tparam[opt] string toref The referent for the symbolic reference.
-- @treturn[1] boolean False if the reference queried and was not found
-- @treturn[2] boolean True if the reference was queried and found (or set)
-- @treturn[2] string The referent of the given reference.
-- @treturn[3] nil Nil on error
-- @treturn[4] string The error message

function repomethod:symbolic_ref(name, toref)
   if toref then
      local ok, ref, err = ll.symbolic_ref { "-q", name, toref,
	 stderr=true, repo=self.path }
      if ok ~= 0 or err:find("Unable to create") then
	 return nil, "Could not set " .. tostring(name) .. " to " .. tostring(toref)
      end
   end
   local ok, ref, err = ll.symbolic_ref { "-q", name, stderr=true, repo=self.path }
   if ok == 0 then
      return true, ref
   end
   if ok == 1 then
      return false
   end
   return nil, (ref or "") .. "\n" .. (err or "")
end

if ll.git2 then
   function repomethod:symbolic_ref(name, toref)
      if toref then
	 -- Set the ref...
	 local ok, err = ll.git2.set_symbolic_ref(self.git2.repo, name, toref)
	 if not ok then
	    return nil, err
	 end
      end
      local symref, err = ll.git2.lookup_symbolic_ref(self.git2.repo, name)
      if not symref then
	 return nil, "No such ref: " .. tostring(toref) .. " (" .. err .. ")"
      end
      return true, symref
   end
end

---
-- Interact with the git configuration.
--
-- @function config
-- @tparam string confname The name of the configuration value to query/set
-- @tparam[opt] value The value to set (or nil to query the current value)
-- @treturn[1] string The value of the configuration entry if queried
-- @treturn[2] nil Nil on error
-- @treturn[2] string The error message

function repomethod:config(confname, value)
   -- Trivial interface to key/value in config
   if not value then
      return self:gather("config", confname)
   else
      self:gather("config", "--replace-all", confname, value)
   end
end

--[[
-- TODO: Add config support to git2.c and convert this
if ll.git2 then
   local old_config = repomethod.config
   function repomethod:config(confname, value)
      local conf = ll.git2.Config.open(self.path .. "/config" )
      if not conf then
	 return old_config(self, confname, value)
      end
      if not value then
	 local v = conf:get_string(confname)
	 return (v and true or nil), (v and v or "Unknown config: " .. confname)
      else
	 if type(value) == "number" then
	    conf:set_int64(value)
	 else
	    conf:set_string(confname, tostring(value))
	 end
	 return true
      end
   end
end
]]

local repomt = {
   __index = repomethod,
   __tostring = _repotostring
}


--- @section end

---
-- Create a new repository object for a given git repository.
--
-- Create a new @{repository} object representing the given git repository.
-- If the path refers to a repository with a working tree (non-bare) then
-- the `/.git` is automatically added.
--
-- @function new
-- @tparam string path The path to the repository to open.
-- @treturn[1] repository The newly created repository.
-- @treturn[2] nil Nil on error.
-- @treturn[2] string The error message.

local function _new(path)
   -- return a new git repository object
   -- with the git_dir set for the provided path
   -- and, if we had to add /.git then the GIT_WORK_DIR set
   -- appropriately too

   local retrepo = {objmemo=setmetatable({}, {__mode="v"})}

   local repopath = path
   local workpath = nil

   local ok = luxio.stat(repopath .. "/.git")
   if ok == 0 then
      repopath = repopath .. "/.git"
      workpath = path
   end

   local symref
   if ll.git2 then
      local git2, msg = ll.git2.open_repo(repopath)
      if not git2 then
	 return nil, "Unable to find Git repository at " .. path
      end
      retrepo.git2 = { repo = git2 }
      symref = ll.git2.lookup_symbolic_ref(git2, "HEAD")
   else
      ok, symref = ll.symbolic_ref { "-q", "HEAD", stderr=true, repo=repopath }
      if ok ~= 0 then
	 return nil, "Unable to find Git repository at " .. path
      end
   end


   retrepo.path = repopath
   retrepo.work = workpath
   retrepo.HEAD = symref

   return setmetatable(retrepo, repomt)
end


---
-- Create a repository
--
-- Create a new repository on disk and return its representation.
--
-- @function create
-- @tparam string path The path to the repository to be created.
-- @tparam boolean full Whether to create a full repository or a bare one.
-- @treturn[1] repository The newly created repository
-- @treturn[2] nil Nil on error
-- @treturn[2] string The error message

local function _create(path, full)
   -- Cause a bare repository to be created (or a non-bare if full is true)
   local args = { stderr = true, repo = path, "-q" }

   if not full then
      args[#args+1] = "--bare"
   end
   ok, msg = ll.init(args)

   if ok ~= 0 then
      return nil, "Unable to create Git repository at " .. path
   end

   -- Otherwise, return the shiny new repo
   return _new(path)
end

return {
   create = _create,
   new = _new
}
