-- gitano.command -- -- Gitano command processing -- -- Copyright 2012 Daniel Silverstone local log = require 'gitano.log' local util = require 'gitano.util' local repository = require 'gitano.repository' local sp = require "luxio.subprocess" local sio = require "luxio.simple" local cmds = {} local function register_cmd(cmdname, short, helptext, validate_fn, prep_fn, run_fn, takes_repo, hidden, is_admin) --[[ log.ddebug("Register command", cmdname) if takes_repo then log.ddebug(" => Takes a repo") end --]] if cmds[cmdname] then log.warn("Attempt to double-register", cmdname) return false, "Attempt to double-register " .. cmdname end cmds[cmdname] = { name = cmdname, validate = validate_fn, prep = prep_fn, run = run_fn, takes_repo = takes_repo, hidden = hidden, admin = is_admin, short = short, helptext = helptext } cmds[#cmds+1] = cmdname table.sort(cmds) return true end local function get_cmd(cmdname) local cmd = cmds[cmdname] if not cmd then return nil, "Unknown command " .. cmdname end return { validate = cmd.validate, prep = cmd.prep, run = cmd.run, takes_repo = cmd.takes_repo, } end local builtin_help_short = "Ask for help" local builtin_help_helptext = [[ usage: help [admin|command] Without the command argument, lists all visible commands. With the command argument, provides detailed help about the given command. If the command argument is specifically 'admin' then list the admin commands instead of the normal commands. If it is 'all' then list all the commands, even the hidden commands. ]] local function builtin_help_validate(config, repo, cmdline) if #cmdline > 2 then log.error("usage: help [admin|command]") return false end if #cmdline == 2 then if cmdline[2] == "all" or cmdline[2] == "admin" then return true end if not cmds[cmdline[2]] then log.error("Unknown command:", help) return false end end return true end local function builtin_help_prep(config, repo, cmdline, context) return "allow", "Always allowed to ask for help" end local function builtin_help_run(config, repo, cmdline, env) local function do_want(cmd) if cmdline[2] == "all" then return true end if cmdline[2] == "admin" then return cmd.admin end return not (cmd.hidden or cmd.admin) end local function do_sep(cmd) local first = cmd.hidden and "-H" or "--" local second = cmd.admin and "A-" or "--" return first .. second end if #cmdline == 1 or cmdline[2] == "admin" or cmdline[2] == "all" then -- List all commands local maxcmdn = 0 for i = 1, #cmds do local cmd = cmds[cmds[i]] local wanted = do_want(cmd) if wanted then if #cmd.name > maxcmdn then maxcmdn = #cmd.name end end end for i = 1, #cmds do local cmd = cmds[cmds[i]] local wanted = do_want(cmd) if wanted then local gap = (" "):rep(maxcmdn - #cmd.name) local desc = (cmd.short or "No description") if cmd.takes_repo then desc = desc .. " (Takes a repo)" end log.state(gap .. cmd.name, do_sep(cmd), desc) end end else local cmd = cmds[cmdline[2]] local desc = (cmd.short or "No description") if cmd.takes_repo then desc = desc .. " (Takes a repo)" end log.state(cmd.name, do_sep(cmd), desc) if cmd.helptext then log.state("") for line in (cmd.helptext):gmatch("([^\n]*)\n") do log.state("=>", line) end end end return "exit", 0 end assert(register_cmd("help", builtin_help_short, builtin_help_helptext, builtin_help_validate, builtin_help_prep, builtin_help_run, false, false)) local function builtin_upload_pack_validate(config, repo, cmdline) -- git-upload-pack repo if #cmdline > 2 then return false end cmdline[2] = repo:fs_path() return true end local function builtin_upload_pack_prep(config, repo, cmdline, context) if repo.is_nascent then return "deny", "Repository " .. repo.name .. " does not exist" end -- git-upload-pack is always a read operation context.operation = "read" return repo:run_lace(context) end local function builtin_upload_pack_run(config, repo, cmdline, env) local cmdcopy = {env=env} for i = 1, #cmdline do cmdcopy[i] = cmdline[i] end local proc = sp.spawn(cmdcopy) return proc:wait() end assert(register_cmd("git-upload-pack", nil, nil, builtin_upload_pack_validate, builtin_upload_pack_prep, builtin_upload_pack_run, true, true)) local function builtin_receive_pack_validate(config, repo, cmdline) -- git-receive-pack repo if #cmdline > 2 then return false end cmdline[2] = repo:fs_path() return true end local function builtin_receive_pack_prep(config, repo, cmdline, context) if repo.is_nascent then return "deny", "Repository " .. repo.name .. " does not exist" end -- git-receive-pack is always a simple write operation context.operation = "write" return repo:run_lace(context) end local function builtin_receive_pack_run(config, repo, cmdline, env) local cmdcopy = {env=env} for i = 1, #cmdline do cmdcopy[i] = cmdline[i] end local proc = sp.spawn(cmdcopy) return proc:wait() end assert(register_cmd("git-receive-pack", nil, nil, builtin_receive_pack_validate, builtin_receive_pack_prep, builtin_receive_pack_run, true, true)) local builtin_create_short = "Create a new repository" local builtin_create_helptext = [[ usage: create [] Create a new repository, optionally setting its owner directly. In order to create a repository, the site administrators must grant you the ability in some part of the namespace. Specifying an owner is equivalent to creating the repository and then calling set-owner to re-assign it. ]] local function builtin_create_validate(config, repo, cmdline) -- create reponame if #cmdline > 3 then log.error("usage: create []") return false end if not repo then log.error("No repository?") return false end if repo and not repo.is_nascent then log.error("Repository", repo.name, "already exists") return false end return true end local function builtin_create_prep(config, repo, cmdline, context) context.operation = "createrepo" return repo:run_lace(context) end local function builtin_create_run(config, repo, cmdline, env) -- Realise the repository log.chat("Creating repository:", repo.name) local ok, msg = repo:realise() if not ok then log.error(msg) return "exit", 1 end local owner = cmdline[3] or env["GITANO_USER"] log.chat("Setting repository owner to", owner) ok, msg = repo:set_owner(owner) if not ok then log.error(msg) return "exit", 1 end log.chat("Running checks to ensure hooks etc are configured") ok, msg = repo:run_checks() if not ok then log.error(msg) return "exit", 1 end log.state("Repository", repo.name, "created ok. Remember to configure rules etc.") return "exit", 0 end assert(register_cmd("create", builtin_create_short, builtin_create_helptext, builtin_create_validate, builtin_create_prep, builtin_create_run, true, false)) local builtin_set_owner_short = "Sets the owner of a repository" local builtin_set_owner_helptext = [[ usage: set-owner Set the owner of a repository. Who is allowed to do this is configured by the site administrators. Typically site admins and repository owners are the only people allowed to change the ownership of a repository. ]] local function builtin_set_owner_validate(config, repo, cmdline) -- set-owner reponame ownername if #cmdline ~= 3 then log.error("usage: set-owner ") return false end if not repo then log.error("No repository?") return false end if repo.is_nascent then log.error("Repository", repo.name, "does not exist, use create") return false end if not config.users[cmdline[3]] then log.error("Unknown user:", cmdline[3]) return false end return true end local function builtin_set_owner_prep(config, repo, cmdline, context) context.operation = "setowner" return repo:run_lace(context) end local function builtin_set_owner_run(config, repo, cmdline, env) local owner = cmdline[3] log.chat("Setting repository owner to", owner) ok, msg = repo:set_owner(owner) if not ok then log.error(msg) return "exit", 1 end log.state("Ownership of repository", repo.name, "transferred ok.") return "exit", 0 end assert(register_cmd("set-owner", builtin_set_owner_short, builtin_set_owner_helptext, builtin_set_owner_validate, builtin_set_owner_prep, builtin_set_owner_run, true, false)) local builtin_set_head_short = "Set the repo's HEAD symbolic reference" local builtin_set_head_helptext = [[ usage: set-head Sets the HEAD of the repository to the given ref. You may need to be the owner of the repository to use this command. ]] local function builtin_set_head_validate(conf, repo, cmdline) if not repo or repo.is_nascent then log.error("Cannot set the HEAD on a nascent repository") return false end if #cmdline ~= 3 then log.error("set-head takes a repository and a ref") return false end return true end local function builtin_set_head_prep(conf, repo, cmdline, context) context.operation = "config_set" context.key = "project.head" context.value = cmdline[3] return repo:run_lace(context) end local function builtin_set_head_run(conf, repo, cmdline, env) local ok, msg = repo:set_head(cmdline[3]) if not ok then log.fatal(msg) end return "exit", 0 end assert(register_cmd("set-head", builtin_set_head_short, builtin_set_head_helptext, builtin_set_head_validate, builtin_set_head_prep, builtin_set_head_run, true, false)) local builtin_set_description_short = "Set the repo's short description" local builtin_set_description_helptext = [[ usage: set-description Description text Sets the short description of the repository to the given text. This text may be used in display tools such as gitweb. ]] local function builtin_set_description_validate(conf, repo, cmdline) if not repo or repo.is_nascent then log.error("Cannot set the description on a nascent repository") return false end if #cmdline < 3 then log.error("set-description takes a repository and a description") return false end if #cmdline > 3 then local cc = util.deep_copy(cmdline) table.remove(cc,1) table.remove(cc,1) cmdline[3] = table.concat(cc, " ") end return true end local function builtin_set_description_prep(conf, repo, cmdline, context) context.operation = "config_set" context.key = "project.description" context.value = cmdline[3] return repo:run_lace(context) end local function builtin_set_description_run(conf, repo, cmdline, env) local ok, msg = repo:set_description(cmdline[3]) if not ok then log.fatal(msg) end return "exit", 0 end assert(register_cmd("set-description", builtin_set_description_short, builtin_set_description_helptext, builtin_set_description_validate, builtin_set_description_prep, builtin_set_description_run, true, false)) local builtin_config_short = "View and change configuration for a repository" local builtin_config_helptext = [[ usage: config [args...] View and manipulate the configuration of a repository. * config show [...] List all configuration variables in which match any of the filters provided. The filters are prefixes which are matched against the keys of the configuration variables. For example: `config sampler list project` will list all the project configuration entries for the sampler.git repository. Keys which represent lists are shown as `foo.*` If you wish to show the detailed key, showing the index of the entry in the list then you should set the filter exactly to `foo.*` which will cause the show command to expand list keys into the form `foo.i_N` where N is the index in the list. * config set key value Set the given configuration key to the given value. If the key ends in `.*` then the system will add the given value to the end of the list represented by the key. To replace a specific entry, set the specific `i_N` entry to the value you want to replace it. * config {del,delete,rm} key Removes the given key from the configuration set. If the key ends in `.*` then the system will remove all configuration values below that prefix. To remove a specific element of a list, instead, be sure to delete the `i_N` entry instead. ]] local function builtin_config_validate(conf, repo, cmdline) if not repo or repo.is_nascent then log.error("Cannot access configuration of a nascent repository") return false end if #cmdline < 3 then cmdline[3] = "show" end if cmdline[3] == "show" then -- No validation to do yet elseif cmdline[3] == "set" then if #cmdline < 5 then log.error("config set: takes a key and a value to set") return false end if #cmdline > 5 then local cpy = {} for i = #cmdline, 5, -1 do table.insert(cpy, 1, cmdline[i]) cmdline[i] = nil end cmdline[5] = table.concat(cpy, " ") end elseif cmdline[3] == "del" or cmdline[3] == "delete" or cmdline[3] == "rm" then cmdline[3] = "del" if #cmdline ~= 4 then log.error("config del: takes a key and nothing more") return false end cmdline.orig_key = cmdline[4] if cmdline[4]:match("%.%*$") then -- Doing a wild removal, expand it now local prefix = cmdline[4]:match("^(.+)%.%*$") cmdline[4] = nil for k in repo.project_config:each(prefix) do cmdline[#cmdline+1] = k end end else log.error("Unknown subcommand <" .. cmdline[3] .. "> for config.") log.info("Valid subcommands are and ") return false end return true end local function builtin_config_prep(conf, repo, cmdline, context) if cmdline[3] == "show" then context.operation = "config_show" for i = 4, #cmdline do local cpy = util.deep_copy(context) cpy.key = cmdline[i] local action, reason = repo:run_lace(cpy) if action ~= "allow" then return action, reason end end return "allow", "Show not denied" elseif cmdline[3] == "set" then context.operation = "config_set" context.key = cmdline[4] context.value = cmdline[5] return repo:run_lace(context) elseif cmdline[3] == "del" then context.operation = "config_del" for i = 4, #cmdline do local cpy = util.deep_copy(context) cpy.key = cmdline[i] local action, reason = repo:run_lace(cpy) if action ~= "allow" then return action, reason end end return "allow", "Delete not denied" end return "deny", "Unknown sub command slipped through" end local function builtin_config_run(conf, repo, cmdline, env) if cmdline[3] == "show" then local all_keys = {} if #cmdline == 3 then for k in repo.project_config:each() do all_keys[k] = true end else for i = 4, #cmdline do for k in repo.project_config:each(cmdline[i]) do all_keys[k] = true end end end -- Transform the all_keys set into a sorted list local slist = {} for k in pairs(all_keys) do slist[#slist+1] = k end -- TODO: Fix this sort to cope with .i_N keys neatly table.sort(slist) for i = 1, #slist do local key = slist[i] local value = repo.project_config.settings[key] local prefix = key:match("^(.+)%.i_[0-9]+$") if prefix then local neatkey = prefix .. ".*" for i = 4, #cmdline do if cmdline[i] == neatkey then neatkey = key break end end end log.stdout(key .. ": " .. value) end elseif cmdline[3] == "set" then local key, value = cmdline[4], cmdline[5] local vtype, rest = value:match("^([sbi]):(.*)$") if vtype then if vtype == "s" then value = rest end if vtype == "i" then value = tonumber(rest) end if vtype == "b" then value = ((rest:lower() == "true") or (rest == "1") or (rest:lower() == "on") or (rest:lower() == "yes")) end end repo.project_config.settings[key] = value local ok, msg = repo:save_admin("Changed project setting: " .. key, env.GITANO_USER) if not ok then log.error(msg) return "exit", 2 end elseif cmdline[3] == "del" then local key = cmdline.orig_key for i = 4, #cmdline do repo.project_config.settings[cmdline[4]] = nil end local ok, msg = repo:save_admin("Deleted project setting: " .. key, env.GITANO_USER) if not ok then log.error(msg) return "exit", 2 end else log.error("Unknown sub command slipped through") return "exit", 1 end return "exit", 0 end assert(register_cmd("config", builtin_config_short, builtin_config_helptext, builtin_config_validate, builtin_config_prep, builtin_config_run, true, false)) local builtin_readme_short = "Access readme for a repository" local builtin_readme_helptext = [[ usage: readme [set|show] If you do not provide a sub-command, the readme command defaults to showing you the readme for the given repository. To view the readme you must have read access to the repository. If you provide the subcommand 'set' then the readme command reads from the input stream and updates the readme in the repository. You must have the right to alter the project readme in order to do this. ]] local function builtin_readme_validate(config, repo, cmdline) if #cmdline < 2 then log.error("Expected repository not provided") return false end if not repo or repo.is_nascent then log.error("Repository does not exist") return false end if #cmdline > 3 then log.error("usage: readme [set|show]") return false end if #cmdline == 2 then cmdline[3] = "show" end if cmdline[3] ~= "set" and cmdline[3] ~= "show" then log.error("usage: readme [set|show]") return false end return true end local function builtin_readme_prep(config, repo, cmdline, context) if cmdline[3] == "show" then context.operation = "read" else context.operation = "setreadme" end return repo:run_lace(context) end local function builtin_readme_run(config, repo, cmdline, env) if cmdline[3] == "show" then local t = repo.readme_mdwn if t then if not t:match("\n$") then t = t .. "\n" end for l in t:gmatch("([^\n]*)\n") do log.state(l) end else log.state("# No README.mdwn found") end else local readme = sio.stdin:read("*a") repo:set_readme(readme) end return "exit", 0 end assert(register_cmd("readme", builtin_readme_short, builtin_readme_helptext, builtin_readme_validate, builtin_readme_prep, builtin_readme_run, true, false)) local builtin_destroy_short = "Destroy (delete) a repository" local builtin_destroy_helptext = [[ usage: destroy [confirmtoken] This command destroys a repository. Run without a confirmation token it will tell the caller what the confirmation token is for that repository. The caller will then run the destroy command again with the confirmation token if they really do wish to destroy the repository. ]] local function builtin_destroy_validate(config, repo, cmdline) if #cmdline < 2 or #cmdline > 3 then log.error("Destroy takes a repository and a (optional) confirmation token") return false end if not repo or repo.is_nascent then log.error("Cannot destroy a repository which does not exist") return false end return true end local function builtin_destroy_prep(config, repo, cmdline, context) context.operation = "destroyrepo" return repo:run_lace(context) end local function builtin_destroy_run(config, repo, cmdline, env) local token = repo:generate_confirmation("destroy repo " .. repo.name) if #cmdline == 2 then -- Generate the confirmation token log.state("") log.state("If you are *certain* you wish to destroy this repository") log.state("Then re-run your command with the following confirmation token:") log.state("") log.state(" ", token) else if cmdline[3] ~= token then log.error("Confirmation token does not match, refusing to destroy") return "exit", 1 end -- Tokens match, ask the repo to destroy itself local nowstamp = os.date("!%Y-%m-%d.%H:%M:%S.UTC") local ok, msg = repo:destroy_self(nowstamp .. "." .. (repo.name:gsub("[^A-Za-z0-9_%.%-]", "_")) .. "." .. token .. ".destroyed") if not ok then log.error(msg) return "exit", 1 end log.state("Should you need to recover the repository you just destroyed") log.state("then you will need to speak with an admin as soon as possible") log.state("") log.state("When you do, be sure to include the current time (" .. nowstamp .. ").") log.state("It may also help if you include your token:") log.state(" ", token) log.state("") log.state("Successfully destroyed", repo.name) end return "exit", 0 end assert(register_cmd("destroy", builtin_destroy_short, builtin_destroy_helptext, builtin_destroy_validate, builtin_destroy_prep, builtin_destroy_run, true, false)) local builtin_rename_short = "Rename a repository" local builtin_rename_helptext = [[ usage: rename Renames a repository to the given new name. In order to do this, you must have the ability to create repositories at the new name, the ability to read the current repository and the ability to rename the current repository. ]] local function builtin_rename_validate(config, repo, cmdline) if #cmdline ~= 3 then log.error("Rename takes a repository and a new name for it") return false end if not repo or repo.is_nascent then log.error("Cannot rename", repo.name, "as it does not exit") return false end return true end local function builtin_rename_prep(config, repo, cmdline, context) local ctx, action, reason -- Check 0, is the current repo nascent if repo.is_nascent then return "deny", "Cannot rename a repository which does not exist" end -- Check 1, read current repo ctx = util.deep_copy(context) ctx.operation = "read" action, reason = repo:run_lace(ctx) if action ~= "allow" then return action, reason end -- Check 2, rename current repo ctx = util.deep_copy(context) ctx.operation = "renamerepo" action, reason = repo:run_lace(ctx) if action ~= "allow" then return action, reason end -- Check 3, create new repo ctx = util.deep_copy(context) local newrepo, msg = repository.find(config, cmdline[3]) if not newrepo then return "deny", msg end if not newrepo.is_nascent then return "deny", "Destination location is in use" end ctx.operation="createrepo" action, reason = newrepo:run_lace(ctx) if action ~= "allow" then return action, reason end -- Okay, we could create, read, and destroy -- thus we can rename return "allow", "Passed all checks, can rename" end local function builtin_rename_run(config, repo, cmdline, env) local ok, msg = repo:rename_to(cmdline[3]) if not ok then log.error(msg) return "exit", 1 end log.state("Renamed", cmdline[2], "to", cmdline[3]) return "exit", 0 end assert(register_cmd("rename", builtin_rename_short, builtin_rename_helptext, builtin_rename_validate, builtin_rename_prep, builtin_rename_run, true, false)) local builtin_ls_short = "List repositories on the server" local builtin_ls_helptext = [[ usage: ls [--verbose|-v] [...] List repositories on the server. If you do not provide a pattern then all repositories are considered, otherwise only ones which match the given patterns will be considered. If you specify --verbose then the head ref name and the description will be provided in addition to the access rights and repository name, separated by tabs. Patterns are a type of extended glob style syntax: ? == any one character except / * == zero or more characters except / ** == zero or more characters including / Any other characters are "as-is" except \ which escapes the next character. If your pattern contains no / and no ** then it will be matched against leafnames of repositories. Note, this means that if you run `ls foo` then the server is going to look for repositories called `foo.git` rather than look in side a folder called `foo/`. For the latter, do `ls foo/` instead. ]] local function builtin_ls_validate(config, _, cmdline) -- For now, anything will do return true end local function builtin_ls_prep(config, _, cmdline, ctx) -- We cheat and store the context for later because we'll need it cmdline._ctx = ctx return "allow", "You can always try and get a listing" end local builtin_ls_special = {} do local builtin_ls_special_chrs = "*?.[]()+-%^$" for c in builtin_ls_special_chrs:gmatch(".") do builtin_ls_special[c] = true end end local function builtin_ls_run(config, _, cmdline, env) -- Step one, parse each pattern into a lua pattern local pats = {} local firstpat, verbose = 2, false if cmdline[firstpat] == "--verbose" or cmdline[firstpat] == "-v" then firstpat, verbose = 3, true end for i = firstpat, #cmdline do local pat, c, input = "", "", cmdline[i] local escaping, star, used_evil = false, false, false c, input = input:match("^(.)(.*)$") while c and c ~= "" do if escaping then pat = pat .. (builtin_ls_special[c] and "%" or "") .. c if c == "/" then used_evil = true end escaping = false else if c == "*" then if star then -- ** pat = pat .. ".*" used_evil = true star = false else star = true end else if star then -- * pat = pat .. "[^/]*" star = false end if c == "?" then pat = pat .. "[^/]" elseif c == "\\" then escaping = true else pat = pat .. (builtin_ls_special[c] and "%" or "") .. c if c == "/" then used_evil = true end end end end c, input = input:match("^(.)(.*)$") end if star then -- spare star pat = pat .. "[^/]*" end if cmdline[i]:match("/$") then pat = pat .. ".*" end if used_evil then pat = "^/" .. pat .. "%.git$" else pat = "/" .. pat .. "%.git$" end log.debug("PAT:", pat) pats[#pats+1] = pat end if #pats == 0 then pats[1] = "." end -- Now we iterate all the repositories, listing them if (a) they pass a -- pattern and (b) they allow the current user to read. local _ctx = cmdline._ctx local function filter_callback(name) for i = 1, #pats do if ("/" .. name):match(pats[i]) then return true end end end local function callback(reponame, repo, msg) if repo then local ctx = util.deep_copy(_ctx) ctx.operation = "read" local action, reason = repo:run_lace(ctx) if action == "allow" then ctx = util.deep_copy(_ctx) ctx.operation = "write" action, reason = repo:run_lace(ctx) local tail = "" if verbose then local desc = repo:conf_get("project.description") desc = desc:gsub("\n.*", "") tail = " " .. repo:conf_get("project.head") .. " " .. desc end log.stdout(action == "allow" and "RW" or "R ", repo.name .. tail) end end end repository.foreach(config, callback, filter_callback) return "exit", 0 end assert(register_cmd("ls", builtin_ls_short, builtin_ls_helptext, builtin_ls_validate, builtin_ls_prep, builtin_ls_run, false, false)) local usercmds = require 'gitano.usercommand' usercmds.register(register_cmd) local admincmds = require 'gitano.admincommand' admincmds.register(register_cmd) local repocmds = require 'gitano.repocommand' repocmds.register(register_cmd) local copycmds = require 'gitano.copycommand' copycmds.register(register_cmd) return { register = register_cmd, get = get_cmd, }