forked from a/repotool
noot
This commit is contained in:
parent
46d7fbab1e
commit
9470d7c36b
18
Makefile
18
Makefile
@ -1,22 +1,18 @@
|
||||
.PHONY: all install
|
||||
|
||||
SOURCES_LIBS:=$(shell find src/lib -type f)
|
||||
SOURCES_SRC:=$(shell find src -type f )
|
||||
LIBS_SRC:=$(shell find lib -type f )
|
||||
REPOTOOL_PATH ?= ${HOME}/repo
|
||||
|
||||
|
||||
all: dist/repotool
|
||||
|
||||
install: dist/repotool repotool.zsh repotool.plugin.zsh
|
||||
install: dist/repotool shell/zsh/repotool.zsh shell/zsh/repotool.plugin.zsh
|
||||
mkdir -p ${REPOTOOL_PATH}/.bin/
|
||||
install dist/repotool repotool.zsh repotool.plugin.zsh ${REPOTOOL_PATH}/.bin/
|
||||
install dist/repotool shell/zsh/repotool.zsh shell/zsh/repotool.plugin.zsh ${REPOTOOL_PATH}/.bin/
|
||||
|
||||
src/lib/repotool/stdlib.sh: $(SOURCES_LIBS)
|
||||
bashly add --source . stdlib -f
|
||||
|
||||
dist/repotool: $(SOURCES_SRC)
|
||||
dist/repotool: $(SOURCES_SRC) $(LIBS_SRC) main.lua
|
||||
@mkdir -p dist
|
||||
@bashly generate
|
||||
@mv repotool dist
|
||||
|
||||
|
||||
luabundler bundle main.lua -p "./src/?.lua" -p "./lib/?.lua" -o dist/repotool
|
||||
@sed -i "1i#!/usr/bin/env luajit" "dist/repotool"
|
||||
chmod +x dist/repotool
|
||||
|
||||
942
lib/cli.lua
Normal file
942
lib/cli.lua
Normal file
@ -0,0 +1,942 @@
|
||||
--
|
||||
-- cmd is a way to declaratively describe command line interfaces
|
||||
--
|
||||
-- It is inspired by the excellent cmdliner[1] library for OCaml.
|
||||
--
|
||||
-- The main idea is to define command line interfaces with "terms".
|
||||
--
|
||||
-- There are "primitive terms" such as "options" and "arguments". Then terms
|
||||
-- could be composed further with "application" or "table" term combinators.
|
||||
--
|
||||
-- Effectively this forms a tree of terms which describes (a) how to parse
|
||||
-- command line arguments and then (b) how to compute a Lua value, finally (c)
|
||||
-- it can be used to automatically generate help messages and man pages.
|
||||
--
|
||||
-- [1]: https://github.com/dbuenzli/cmdliner
|
||||
--
|
||||
|
||||
--
|
||||
-- PRELUDE
|
||||
--
|
||||
-- {{{
|
||||
|
||||
local argv = arg
|
||||
|
||||
--
|
||||
-- Iterate both keys and then indecies in a sorted manner.
|
||||
--
|
||||
local function spairs(t)
|
||||
local keys = {}
|
||||
|
||||
for k, _ in pairs(t) do
|
||||
if type(k) == 'string' then
|
||||
table.insert(keys, k)
|
||||
end
|
||||
end
|
||||
table.sort(keys)
|
||||
|
||||
for idx, _ in ipairs(t) do
|
||||
table.insert(keys, idx)
|
||||
end
|
||||
|
||||
local it = ipairs(keys)
|
||||
local i = 0
|
||||
return function()
|
||||
i, k = it(keys, i)
|
||||
if i == nil then return nil end
|
||||
return k, t[k]
|
||||
end
|
||||
end
|
||||
|
||||
-- }}}
|
||||
|
||||
--
|
||||
-- PARSING AND EVALUATION
|
||||
--
|
||||
-- {{{
|
||||
|
||||
--
|
||||
-- Split text line into an array of shell words respecting quoting.
|
||||
--
|
||||
local function shell_split(text)
|
||||
local line = {}
|
||||
local spat, epat, buf, quoted = [=[^(['"])]=], [=[(['"])$]=]
|
||||
for str in text:gmatch("%S+") do
|
||||
local squoted = str:match(spat)
|
||||
local equoted = str:match(epat)
|
||||
local escaped = str:match([=[(\*)['"]$]=])
|
||||
if squoted and not quoted and not equoted then
|
||||
buf, quoted = str, squoted
|
||||
elseif buf and equoted == quoted and #escaped % 2 == 0 then
|
||||
str, buf, quoted = buf .. ' ' .. str, nil, nil
|
||||
elseif buf then
|
||||
buf = buf .. ' ' .. str
|
||||
end
|
||||
if not buf then
|
||||
table.insert(line, (str:gsub(spat, ""):gsub(epat, "")))
|
||||
end
|
||||
end
|
||||
if buf then table.insert(line, buf) end
|
||||
return line
|
||||
end
|
||||
|
||||
local ZSH_COMPLETION_SCRIPT = [=[
|
||||
function _NAME {
|
||||
local -a completions
|
||||
response=("${(@f)$(env COMP_WORDS="${words[*]}" COMP_CWORD="${CURRENT}" "NAME")}")
|
||||
for type key desc in ${response}; do
|
||||
if [[ "$type" == "item" ]]; then
|
||||
completions+=("$key":"$desc")
|
||||
elif [[ "$type" == "dir" ]]; then
|
||||
_path_files -/
|
||||
elif [[ "$type" == "file" ]]; then
|
||||
_path_files -f
|
||||
fi
|
||||
done
|
||||
if [ -n "$completions" ]; then
|
||||
_describe -V unsorted completions -U
|
||||
fi
|
||||
}
|
||||
|
||||
compdef _NAME NAME
|
||||
]=]
|
||||
|
||||
local BASH_COMPLETION_SCRIPT = [=[
|
||||
_NAME() {
|
||||
local IFS=$'\n'
|
||||
while read type; read value; read _desc; do
|
||||
if [[ $type == 'dir' ]] && (type compopt &> /dev/null); then
|
||||
COMPREPLY=()
|
||||
compopt -o dirnames
|
||||
elif [[ $type == 'file' ]] && (type compopt &> /dev/null); then
|
||||
COMPREPLY=()
|
||||
compopt -o default
|
||||
elif [[ $type == 'item' ]]; then
|
||||
COMPREPLY+=($value)
|
||||
fi
|
||||
done < <(env COMP_WORDS="$(IFS=',' echo "${COMP_WORDS[*]}")" COMP_CWORD=$((COMP_CWORD+1)) "NAME")
|
||||
return 0
|
||||
}
|
||||
_NAME_setup() {
|
||||
complete -F _NAME NAME
|
||||
}
|
||||
_NAME_setup;
|
||||
]=]
|
||||
|
||||
--
|
||||
-- Raise an error which should be reported to user
|
||||
--
|
||||
local function err(msg, ...)
|
||||
coroutine.yield {
|
||||
action = 'error',
|
||||
message = string.format(msg, ...),
|
||||
}
|
||||
end
|
||||
|
||||
--
|
||||
-- Traverse terms and yield primitive terms (options and arguments).
|
||||
--
|
||||
local function primitives(term)
|
||||
local queue = {term}
|
||||
local function next()
|
||||
local t = table.remove(queue, 1)
|
||||
if t == nil then
|
||||
return nil
|
||||
elseif t.type == 'opt' then
|
||||
return t
|
||||
elseif t.type == 'arg' then
|
||||
return t
|
||||
elseif t.type == 'val' then
|
||||
return next()
|
||||
elseif t.type == 'app' then
|
||||
table.insert(queue, t.f)
|
||||
for _, a in ipairs(t.args) do
|
||||
table.insert(queue, a)
|
||||
end
|
||||
return next()
|
||||
elseif t.type == 'all' then
|
||||
for _, v in spairs(t.spec) do
|
||||
table.insert(queue, v)
|
||||
end
|
||||
return next()
|
||||
else
|
||||
assert(false, 'unknown term')
|
||||
end
|
||||
end
|
||||
return next
|
||||
end
|
||||
|
||||
--
|
||||
-- A special token within line which specifies the position for completion.
|
||||
--
|
||||
local __COMPLETE__ = '__COMPLETE__'
|
||||
local arg, opt
|
||||
|
||||
local function parse_and_eval(cmd, line)
|
||||
local is_completion = line.cword ~= nil
|
||||
|
||||
--
|
||||
-- Iterator which given a command `cmd` and an `idx` into `line` yield a new
|
||||
-- `idx`, `term`, `value` triple.
|
||||
--
|
||||
local function terms(cmd, idx)
|
||||
local opts = {}
|
||||
for k, v in pairs(cmd.lookup.opts) do opts[k] = v end
|
||||
local args = {unpack(cmd.lookup.args)}
|
||||
|
||||
idx = idx or 1
|
||||
local function next()
|
||||
local v = line[idx]
|
||||
if v == nil then return end
|
||||
|
||||
if v:sub(1, 2) == "--" or v:sub(1, 1) == "-" then
|
||||
local name, value = v, nil
|
||||
|
||||
-- Check if option is supplied as '--name=value' and parse it accordingly.
|
||||
local sep = v:find("=", 1, true)
|
||||
if sep then
|
||||
name, value = v:sub(0, sep - 1), v:sub(sep + 1)
|
||||
end
|
||||
|
||||
local o = opts[name]
|
||||
if o == nil then
|
||||
coroutine.yield {
|
||||
action = 'error',
|
||||
message = string.format("unknown option '%s'", name)
|
||||
}
|
||||
-- Recover by synthesizing a dummy option flag
|
||||
o = opt { "--ERROR", flag = true }
|
||||
end
|
||||
|
||||
if o.flag then
|
||||
if value ~= nil then
|
||||
coroutine.yield {
|
||||
action = 'error',
|
||||
message = string.format("unexpected value for option '%s'", name)
|
||||
}
|
||||
end
|
||||
idx = idx + 1
|
||||
return idx, o, true
|
||||
else
|
||||
if not value then
|
||||
idx, value = idx + 1, line[idx + 1]
|
||||
if value == __COMPLETE__ then
|
||||
coroutine.yield {
|
||||
action = 'completion',
|
||||
cmd = cmd,
|
||||
opt = o,
|
||||
arg = nil,
|
||||
}
|
||||
end
|
||||
if value == nil then
|
||||
coroutine.yield {
|
||||
action = 'error',
|
||||
message = string.format("missing value for option '%s'", name)
|
||||
}
|
||||
end
|
||||
end
|
||||
idx = idx + 1
|
||||
return idx, o, value
|
||||
end
|
||||
else
|
||||
local a = args[1]
|
||||
if v == __COMPLETE__ then
|
||||
coroutine.yield {
|
||||
action = 'completion',
|
||||
cmd = cmd,
|
||||
opt = nil,
|
||||
arg = a,
|
||||
}
|
||||
elseif a == nil then
|
||||
coroutine.yield {
|
||||
action = 'error',
|
||||
message = string.format("unexpected argument '%s'", v)
|
||||
}
|
||||
-- Recover by synthesizing a dummy arg
|
||||
a = arg "ERROR"
|
||||
end
|
||||
if not a.plural then
|
||||
table.remove(args, 1)
|
||||
end
|
||||
idx = idx + 1
|
||||
return idx, a, v
|
||||
end
|
||||
end
|
||||
return next
|
||||
end
|
||||
|
||||
--
|
||||
-- Eval `term` given values for `opts` and `args`.
|
||||
--
|
||||
local function eval(term, opts, args)
|
||||
if term.type == 'opt' then
|
||||
local v = opts[term.name]
|
||||
if term.plural and v == nil then v = {} end
|
||||
if term.flag and v == nil then v = false end
|
||||
return v
|
||||
elseif term.type == 'arg' then
|
||||
local v = table.remove(args, 1)
|
||||
if term.plural and v == nil then v = {} end
|
||||
return v
|
||||
elseif term.type == 'val' then
|
||||
return term.v
|
||||
elseif term.type == 'app' then
|
||||
local v = {}
|
||||
for i, t in ipairs(term.args) do
|
||||
v[i] = eval(t, opts, args)
|
||||
end
|
||||
return term.func(unpack(v))
|
||||
elseif term.type == 'all' then
|
||||
local v = {}
|
||||
for k, t in pairs(term.spec) do
|
||||
v[k] = eval(t, opts, args)
|
||||
end
|
||||
return v
|
||||
else
|
||||
assert(false)
|
||||
end
|
||||
end
|
||||
|
||||
local function run(cmd, start_idx)
|
||||
local has_subs = cmd.subs and #cmd.subs > 0
|
||||
assert(not has_subs or #cmd.lookup.args == 1)
|
||||
|
||||
local opts, args = {}, {}
|
||||
for idx, term, value in terms(cmd, start_idx) do
|
||||
if term.type == 'opt' then
|
||||
if not cmd.disable_help and term.name == "--help" then
|
||||
coroutine.yield {
|
||||
action = 'help',
|
||||
cmd = cmd,
|
||||
}
|
||||
elseif not cmd.disable_version and term.name == "--version" then
|
||||
coroutine.yield {
|
||||
action = 'version',
|
||||
cmd = cmd,
|
||||
}
|
||||
else
|
||||
if term.plural then
|
||||
if opts[term.name] ~= nil then
|
||||
table.insert(opts[term.name], value)
|
||||
else
|
||||
opts[term.name] = {value}
|
||||
end
|
||||
else
|
||||
if opts[term.name] ~= nil then
|
||||
coroutine.yield {
|
||||
action = 'error',
|
||||
message = string.format("supplied multiple values for option '%s'", term.name)
|
||||
}
|
||||
else
|
||||
opts[term.name] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif term.type == 'arg' then
|
||||
if has_subs then
|
||||
-- First (and the only) argument for the command with subcommands is a
|
||||
-- subcommand name. Lookup subcommand and continue running with the
|
||||
-- subcommand.
|
||||
local next_cmd = cmd.lookup.subs[value]
|
||||
if next_cmd == nil then
|
||||
coroutine.yield {
|
||||
action = 'error',
|
||||
message = string.format("unknown subcommand '%s'", value)
|
||||
}
|
||||
else
|
||||
coroutine.yield {
|
||||
action = 'value',
|
||||
term = cmd.term,
|
||||
opts = opts,
|
||||
args = {},
|
||||
}
|
||||
return run(next_cmd, idx)
|
||||
end
|
||||
else
|
||||
if term.plural then
|
||||
if type(args[#args]) == 'table' then
|
||||
table.insert(args[#args], value)
|
||||
else
|
||||
table.insert(args, {value})
|
||||
end
|
||||
else
|
||||
table.insert(args, value)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if has_subs and cmd.lookup.subs.required then
|
||||
coroutine.yield {
|
||||
action = 'error',
|
||||
message = 'missing a subcommand',
|
||||
}
|
||||
else
|
||||
local next_arg = cmd.lookup.args[#args + 1]
|
||||
if next_arg and next_arg.required then
|
||||
coroutine.yield {
|
||||
action = 'error',
|
||||
message = string.format("missing a required argument '%s'", next_arg.name),
|
||||
}
|
||||
end
|
||||
|
||||
coroutine.yield {
|
||||
action = 'value',
|
||||
term = cmd.term,
|
||||
opts = opts,
|
||||
args = args,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
local values = {n = 0}
|
||||
local errors = {}
|
||||
local show_version, show_help
|
||||
|
||||
do
|
||||
local co = coroutine.create(function() run(cmd) end)
|
||||
while coroutine.status(co) ~= 'dead' do
|
||||
local ok, val = coroutine.resume(co)
|
||||
if not ok then
|
||||
error(val .. '\n' .. debug.traceback(co))
|
||||
elseif not val then
|
||||
-- do nothing
|
||||
elseif val.action == 'value' then
|
||||
values.n = values.n + 1
|
||||
values[values.n] = val
|
||||
elseif val.action == 'error' then
|
||||
table.insert(errors, val.message)
|
||||
elseif val.action == 'help' then
|
||||
show_help = {cmd = val.cmd}
|
||||
elseif val.action == 'version' then
|
||||
show_version = {cmd = val.cmd}
|
||||
elseif val.action == 'completion' then
|
||||
assert(is_completion)
|
||||
return 'completion', val.cmd:completion(line.cword, val.opt, val.arg)
|
||||
else
|
||||
assert(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if show_help ~= nil then
|
||||
return 'help', show_help
|
||||
end
|
||||
|
||||
if show_version ~= nil then
|
||||
return 'version', show_version
|
||||
end
|
||||
|
||||
if #errors > 0 then
|
||||
return 'error', errors[1]
|
||||
end
|
||||
|
||||
do
|
||||
local co = coroutine.create(function()
|
||||
for i=1,values.n do
|
||||
local val = values[i]
|
||||
values[i] = eval(val.term, val.opts, val.args)
|
||||
end
|
||||
end)
|
||||
local status, val = true, nil
|
||||
while coroutine.status(co) ~= 'dead' do
|
||||
local ok, val = coroutine.resume(co)
|
||||
if not ok then
|
||||
error(val .. '\n' .. debug.traceback(co))
|
||||
elseif not val then
|
||||
-- do nothing
|
||||
elseif val.action == 'error' then
|
||||
return 'error', val.message
|
||||
else
|
||||
assert(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function unwind(i)
|
||||
if i > values.n then return nil
|
||||
else return values[i], unwind(i + 1)
|
||||
end
|
||||
end
|
||||
|
||||
return 'value', unwind(1)
|
||||
end
|
||||
|
||||
local function run(cmd, line)
|
||||
line = line or argv
|
||||
|
||||
-- Print shell completion script onto stdout and exit
|
||||
local comp_prog = os.getenv "COMP_PROG"
|
||||
local comp_shell = os.getenv "COMP_SHELL"
|
||||
if comp_prog ~= nil and comp_shell ~=nil then
|
||||
if comp_shell == "zsh" then
|
||||
print((ZSH_COMPLETION_SCRIPT:gsub("NAME", comp_prog)))
|
||||
elseif comp_shell == "bash" then
|
||||
print((BASH_COMPLETION_SCRIPT:gsub("NAME", comp_prog)))
|
||||
end
|
||||
os.exit(0)
|
||||
end
|
||||
|
||||
-- Check if we are running completion
|
||||
do
|
||||
local comp_words = os.getenv "COMP_WORDS"
|
||||
local comp_cword = tonumber(os.getenv "COMP_CWORD")
|
||||
|
||||
if comp_words ~= nil and comp_cword ~= nil then
|
||||
line = shell_split(comp_words)
|
||||
line.cword = line[comp_cword] or ""
|
||||
line[comp_cword] = __COMPLETE__
|
||||
table.remove(line, 1)
|
||||
end
|
||||
end
|
||||
|
||||
local function handle(type, v, ...)
|
||||
if type == 'value' then
|
||||
return v, ...
|
||||
elseif type == 'error' then
|
||||
cmd:print_error(v)
|
||||
os.exit(1)
|
||||
elseif type == 'help' then
|
||||
v.cmd:print_help()
|
||||
os.exit(0)
|
||||
elseif type == 'version' then
|
||||
v.cmd:print_version()
|
||||
os.exit(0)
|
||||
elseif type == 'completion' then
|
||||
for type, name, desc in v do
|
||||
print(type); print(name or ""); print(desc or "")
|
||||
end
|
||||
os.exit(0)
|
||||
end
|
||||
end
|
||||
|
||||
return handle(parse_and_eval(cmd, line))
|
||||
end
|
||||
|
||||
-- }}}
|
||||
|
||||
--
|
||||
-- TERMS
|
||||
--
|
||||
-- Terms form an algebra, there are primitive terms and then term compositions
|
||||
-- (which are also terms!) so you can compose more complex terms out of simpler
|
||||
-- terms.
|
||||
--
|
||||
-- The primitive terms represent command line options and arguments.
|
||||
--
|
||||
-- {{{
|
||||
|
||||
local app
|
||||
local cmd
|
||||
|
||||
--
|
||||
-- Completion function which completes nothing.
|
||||
--
|
||||
local empty_complete = function()
|
||||
return pairs {}
|
||||
end
|
||||
|
||||
--
|
||||
-- Completion functions which completes filenames.
|
||||
--
|
||||
local file_complete = function()
|
||||
local e = false
|
||||
return function()
|
||||
if not e then
|
||||
e = true
|
||||
return "file", nil, nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--
|
||||
-- Completion functions which completes dirnames.
|
||||
--
|
||||
local dir_complete = function()
|
||||
local e = false
|
||||
return function()
|
||||
if not e then
|
||||
e = true
|
||||
return "dir", nil, nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local term_mt = {__index = {}}
|
||||
|
||||
function term_mt.__index:and_then(func)
|
||||
return app(func, self)
|
||||
end
|
||||
|
||||
function term_mt.__index:parse_and_eval(line)
|
||||
local command = cmd { term = self, disable_help = true, disable_version = true }
|
||||
return parse_and_eval(command, line)
|
||||
end
|
||||
|
||||
--
|
||||
-- Construct a term out of Lua value.
|
||||
--
|
||||
local function val(v)
|
||||
return setmetatable({type = 'val', v = v}, term_mt)
|
||||
end
|
||||
|
||||
--
|
||||
-- Construct a term which applies a given Lua function to the result of
|
||||
-- evaluating argument terms.
|
||||
--
|
||||
function app(func, ...)
|
||||
return setmetatable({type = 'app', func = func, args = {...}}, term_mt)
|
||||
end
|
||||
|
||||
--
|
||||
-- Construct a term which evaluates into a table.
|
||||
--
|
||||
local function all(spec)
|
||||
return setmetatable({ type = 'all', spec = spec }, term_mt)
|
||||
end
|
||||
|
||||
--
|
||||
-- Construct a term which represents a command line option.
|
||||
--
|
||||
function opt(spec)
|
||||
if type(spec) == 'string' then spec = {spec} end
|
||||
|
||||
assert(
|
||||
not (spec.flag and spec.plural),
|
||||
"opt { plural = true, flag = true, ...} does not make sense"
|
||||
)
|
||||
|
||||
-- add '-' short options or '--' for long options
|
||||
local names = {}
|
||||
for _, n in ipairs(spec) do
|
||||
if not n:sub(1, 1) == "-" then
|
||||
if #n == 1 then
|
||||
n = "-" .. n
|
||||
else
|
||||
n = "--" .. n
|
||||
end
|
||||
end
|
||||
table.insert(names, n)
|
||||
end
|
||||
|
||||
local complete
|
||||
if spec.complete == "file" then
|
||||
complete = file_complete
|
||||
elseif spec.complete == "dir" then
|
||||
complete = dir_complete
|
||||
elseif spec.complete then
|
||||
complete = spec.complete
|
||||
else
|
||||
complete = empty_complete
|
||||
end
|
||||
|
||||
return setmetatable({
|
||||
type = 'opt',
|
||||
name = names[1],
|
||||
names = names,
|
||||
desc = spec.desc or "NOT DOCUMENTED",
|
||||
vdesc = spec.vdesc or 'VALUE',
|
||||
flag = spec.flag or false,
|
||||
plural = spec.plural or false,
|
||||
complete = complete,
|
||||
}, term_mt)
|
||||
end
|
||||
|
||||
--
|
||||
-- Construct a term which represents a command line positional argument.
|
||||
--
|
||||
function arg(spec)
|
||||
local name
|
||||
if type(spec) == 'string' then
|
||||
name = spec
|
||||
else
|
||||
name = spec[1]
|
||||
end
|
||||
|
||||
assert(name, "missing arg name")
|
||||
|
||||
local complete
|
||||
if spec.complete == "file" then
|
||||
complete = file_complete
|
||||
elseif spec.complete == "dir" then
|
||||
complete = dir_complete
|
||||
elseif spec.complete then
|
||||
complete = spec.complete
|
||||
else
|
||||
complete = empty_complete
|
||||
end
|
||||
local required = false
|
||||
if spec.required == nil or spec.required then
|
||||
required = true
|
||||
end
|
||||
return setmetatable({
|
||||
type = 'arg',
|
||||
name = name,
|
||||
desc = spec.desc or "NOT DOCUMENTED",
|
||||
complete = complete,
|
||||
required = required,
|
||||
plural = spec.plural or false,
|
||||
}, term_mt)
|
||||
end
|
||||
|
||||
-- }}}
|
||||
|
||||
--
|
||||
-- COMMANDS
|
||||
--
|
||||
-- A command wraps a term and adds some convenience like automatic parsing and
|
||||
-- processing of --help and --version options, handling of user errors.
|
||||
--
|
||||
-- Commands can be contain other subcommands enabling command line interfaces
|
||||
-- like git or kubectl which became popular recently.
|
||||
--
|
||||
-- {{{
|
||||
|
||||
local help_opt = opt {
|
||||
'--help', '-h',
|
||||
flag = true,
|
||||
desc = 'Show this message and exit',
|
||||
}
|
||||
|
||||
local version_opt = opt {
|
||||
'--version',
|
||||
flag = true,
|
||||
desc = 'Print version and exit',
|
||||
}
|
||||
|
||||
local cmd_mt = {
|
||||
__index = {
|
||||
run = run,
|
||||
parse_and_eval = parse_and_eval,
|
||||
|
||||
print_error = function(self, err)
|
||||
io.stderr:write(string.format("%s: error: %s\n", self.name, err))
|
||||
end,
|
||||
|
||||
print_version = function(self)
|
||||
print(self.version)
|
||||
end,
|
||||
|
||||
print_help = function(self)
|
||||
local function print_tabular(rows, opts)
|
||||
opts = opts or {}
|
||||
local margin = opts.margin or 2
|
||||
local width = {}
|
||||
for _, row in ipairs(rows) do
|
||||
for i, col in ipairs(row) do
|
||||
if #col > (width[i] or 0) then width[i] = #col end
|
||||
end
|
||||
end
|
||||
|
||||
for _, row in ipairs(rows) do
|
||||
local line = ''
|
||||
local prev_col_width = 0
|
||||
for i, col in ipairs(row) do
|
||||
local padding = (' '):rep((width[i - 1] or 0) - prev_col_width + margin)
|
||||
line = line .. padding .. col
|
||||
prev_col_width = #col
|
||||
end
|
||||
print(line)
|
||||
end
|
||||
end
|
||||
|
||||
if self.version and self.name then
|
||||
print(string.format("%s v%s", self.name, self.version))
|
||||
elseif self.name then
|
||||
print(self.name)
|
||||
end
|
||||
if self.desc then
|
||||
print("")
|
||||
print(self.desc)
|
||||
end
|
||||
|
||||
local opts, args = {}, {}
|
||||
for item in primitives(self.term) do
|
||||
if item.type == 'opt' then
|
||||
table.insert(opts, item)
|
||||
elseif item.type == 'arg' then
|
||||
table.insert(args, item)
|
||||
end
|
||||
end
|
||||
|
||||
if not self.disable_help then
|
||||
table.insert(opts, help_opt)
|
||||
end
|
||||
if not self.disable_version then
|
||||
table.insert(opts, version_opt)
|
||||
end
|
||||
|
||||
if #opts > 0 then
|
||||
print('\nOptions:')
|
||||
local rows = {}
|
||||
for _, o in ipairs(opts) do
|
||||
local name = table.concat(o.names, ',')
|
||||
if not o.flag then
|
||||
name = name .. ' ' .. o.vdesc
|
||||
end
|
||||
table.insert(rows, {name, o.desc})
|
||||
end
|
||||
print_tabular(rows)
|
||||
end
|
||||
|
||||
if not self.subs and #args > 0 then
|
||||
print('\nArguments:')
|
||||
local rows = {}
|
||||
for _, a in ipairs(args) do
|
||||
table.insert(rows, {a.name, a.desc})
|
||||
end
|
||||
print_tabular(rows)
|
||||
end
|
||||
|
||||
if self.subs and #self.subs > 0 then
|
||||
print('\nCommands:')
|
||||
local rows = {}
|
||||
for _, c in ipairs(self.subs) do
|
||||
local name = table.concat(c.names, ',')
|
||||
table.insert(rows, {name, c.desc or ''})
|
||||
end
|
||||
print_tabular(rows)
|
||||
end
|
||||
end,
|
||||
|
||||
completion = function(self, cword, opt, arg)
|
||||
local co = coroutine.create(function()
|
||||
local function out(type, name, desc)
|
||||
if name == nil or name:sub(1, #cword) == cword then
|
||||
coroutine.yield(type, name, desc)
|
||||
end
|
||||
end
|
||||
|
||||
local function complete_term(term)
|
||||
for type, name, desc in term.complete(cword) do
|
||||
out(type, name, desc)
|
||||
end
|
||||
end
|
||||
|
||||
if opt then
|
||||
-- Complete option value
|
||||
-- TODO(andreypopp): handle `--name=value` syntax here
|
||||
complete_term(opt)
|
||||
else
|
||||
if self.subs and #self.subs > 0 then
|
||||
-- Complete subcommands
|
||||
for _, c in ipairs(self.subs) do
|
||||
out("item", c.name, c.desc)
|
||||
end
|
||||
end
|
||||
|
||||
-- Finally complete option names
|
||||
for t in primitives(self.term) do
|
||||
if t.type == 'opt' then
|
||||
out("item", t.name, t.desc)
|
||||
end
|
||||
end
|
||||
if not self.disable_help then
|
||||
out("item", help_opt.name, help_opt.desc)
|
||||
end
|
||||
if not self.disable_version then
|
||||
out("item", version_opt.name, version_opt.desc)
|
||||
end
|
||||
|
||||
if arg then
|
||||
-- Complete argument value
|
||||
complete_term(arg)
|
||||
end
|
||||
|
||||
end
|
||||
end)
|
||||
|
||||
return function()
|
||||
local ok, type, name, desc = coroutine.resume(co)
|
||||
if not ok then error(type) end
|
||||
return type, name, desc
|
||||
end
|
||||
end,
|
||||
}
|
||||
}
|
||||
|
||||
function cmd(spec)
|
||||
if type(spec) == 'string' then
|
||||
spec = {spec}
|
||||
end
|
||||
|
||||
-- Build an lookup for args, opts and subcommands.
|
||||
local lookup = {}
|
||||
do
|
||||
local opts, args, subs = {}, {}, {}
|
||||
|
||||
for term in primitives(spec.term) do
|
||||
if term.type == 'opt' then
|
||||
for _, n in ipairs(term.names) do
|
||||
opts[n] = term
|
||||
end
|
||||
elseif term.type == 'arg' then
|
||||
table.insert(args, term)
|
||||
end
|
||||
end
|
||||
|
||||
if spec.subs and #spec.subs > 0 then
|
||||
subs.required = false
|
||||
if spec.subs.required == nil or spec.subs.required then
|
||||
subs.required = true
|
||||
end
|
||||
for _, s in ipairs(spec.subs) do
|
||||
for _, n in ipairs(s.names) do
|
||||
subs[n] = s
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if subs and next(subs) ~= nil then
|
||||
assert(#args == 0, "a command with subcommands cannot accept arguments")
|
||||
table.insert(args, arg {'subcommand', required = subs.required})
|
||||
end
|
||||
|
||||
if not spec.disable_help then
|
||||
opts['--help'] = help_opt
|
||||
opts['-h'] = help_opt
|
||||
end
|
||||
if not spec.disable_version then
|
||||
opts['--version'] = version_opt
|
||||
end
|
||||
|
||||
lookup.opts = opts
|
||||
lookup.args = args
|
||||
lookup.subs = subs
|
||||
end
|
||||
|
||||
local names = {}
|
||||
for _, n in ipairs(spec) do
|
||||
table.insert(names, n)
|
||||
end
|
||||
|
||||
return setmetatable({
|
||||
name = names[1],
|
||||
names = names,
|
||||
version = spec.version or "0.0.0",
|
||||
desc = spec.desc or "NOT DOCUMENTED",
|
||||
term = spec.term or val(),
|
||||
subs = spec.subs,
|
||||
lookup = lookup,
|
||||
disable_help = spec.disable_help,
|
||||
disable_version = spec.disable_version,
|
||||
}, cmd_mt)
|
||||
end
|
||||
|
||||
-- }}}
|
||||
|
||||
--
|
||||
-- EXPORTS
|
||||
--
|
||||
|
||||
return {
|
||||
cmd = cmd,
|
||||
opt = opt,
|
||||
arg = arg,
|
||||
all = all,
|
||||
app = app,
|
||||
val = val,
|
||||
err = err,
|
||||
-- This is exported for testing purposes only
|
||||
__COMPLETE__ = __COMPLETE__,
|
||||
shell_split = shell_split,
|
||||
}
|
||||
388
lib/json.lua
Normal file
388
lib/json.lua
Normal file
@ -0,0 +1,388 @@
|
||||
--
|
||||
-- json.lua
|
||||
--
|
||||
-- Copyright (c) 2020 rxi
|
||||
--
|
||||
-- Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
-- this software and associated documentation files (the "Software"), to deal in
|
||||
-- the Software without restriction, including without limitation the rights to
|
||||
-- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
-- of the Software, and to permit persons to whom the Software is furnished to do
|
||||
-- so, subject to the following conditions:
|
||||
--
|
||||
-- The above copyright notice and this permission notice shall be included in all
|
||||
-- copies or substantial portions of the Software.
|
||||
--
|
||||
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
-- SOFTWARE.
|
||||
--
|
||||
|
||||
local json = { _version = "0.1.2" }
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
-- Encode
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
local encode
|
||||
|
||||
local escape_char_map = {
|
||||
[ "\\" ] = "\\",
|
||||
[ "\"" ] = "\"",
|
||||
[ "\b" ] = "b",
|
||||
[ "\f" ] = "f",
|
||||
[ "\n" ] = "n",
|
||||
[ "\r" ] = "r",
|
||||
[ "\t" ] = "t",
|
||||
}
|
||||
|
||||
local escape_char_map_inv = { [ "/" ] = "/" }
|
||||
for k, v in pairs(escape_char_map) do
|
||||
escape_char_map_inv[v] = k
|
||||
end
|
||||
|
||||
|
||||
local function escape_char(c)
|
||||
return "\\" .. (escape_char_map[c] or string.format("u%04x", c:byte()))
|
||||
end
|
||||
|
||||
|
||||
local function encode_nil(val)
|
||||
return "null"
|
||||
end
|
||||
|
||||
|
||||
local function encode_table(val, stack)
|
||||
local res = {}
|
||||
stack = stack or {}
|
||||
|
||||
-- Circular reference?
|
||||
if stack[val] then error("circular reference") end
|
||||
|
||||
stack[val] = true
|
||||
|
||||
if rawget(val, 1) ~= nil or next(val) == nil then
|
||||
-- Treat as array -- check keys are valid and it is not sparse
|
||||
local n = 0
|
||||
for k in pairs(val) do
|
||||
if type(k) ~= "number" then
|
||||
error("invalid table: mixed or invalid key types")
|
||||
end
|
||||
n = n + 1
|
||||
end
|
||||
if n ~= #val then
|
||||
error("invalid table: sparse array")
|
||||
end
|
||||
-- Encode
|
||||
for i, v in ipairs(val) do
|
||||
table.insert(res, encode(v, stack))
|
||||
end
|
||||
stack[val] = nil
|
||||
return "[" .. table.concat(res, ",") .. "]"
|
||||
|
||||
else
|
||||
-- Treat as an object
|
||||
for k, v in pairs(val) do
|
||||
if type(k) ~= "string" then
|
||||
error("invalid table: mixed or invalid key types")
|
||||
end
|
||||
table.insert(res, encode(k, stack) .. ":" .. encode(v, stack))
|
||||
end
|
||||
stack[val] = nil
|
||||
return "{" .. table.concat(res, ",") .. "}"
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
local function encode_string(val)
|
||||
return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"'
|
||||
end
|
||||
|
||||
|
||||
local function encode_number(val)
|
||||
-- Check for NaN, -inf and inf
|
||||
if val ~= val or val <= -math.huge or val >= math.huge then
|
||||
error("unexpected number value '" .. tostring(val) .. "'")
|
||||
end
|
||||
return string.format("%.14g", val)
|
||||
end
|
||||
|
||||
|
||||
local type_func_map = {
|
||||
[ "nil" ] = encode_nil,
|
||||
[ "table" ] = encode_table,
|
||||
[ "string" ] = encode_string,
|
||||
[ "number" ] = encode_number,
|
||||
[ "boolean" ] = tostring,
|
||||
}
|
||||
|
||||
|
||||
encode = function(val, stack)
|
||||
local t = type(val)
|
||||
local f = type_func_map[t]
|
||||
if f then
|
||||
return f(val, stack)
|
||||
end
|
||||
error("unexpected type '" .. t .. "'")
|
||||
end
|
||||
|
||||
|
||||
function json.encode(val)
|
||||
return ( encode(val) )
|
||||
end
|
||||
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
-- Decode
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
local parse
|
||||
|
||||
local function create_set(...)
|
||||
local res = {}
|
||||
for i = 1, select("#", ...) do
|
||||
res[ select(i, ...) ] = true
|
||||
end
|
||||
return res
|
||||
end
|
||||
|
||||
local space_chars = create_set(" ", "\t", "\r", "\n")
|
||||
local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",")
|
||||
local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u")
|
||||
local literals = create_set("true", "false", "null")
|
||||
|
||||
local literal_map = {
|
||||
[ "true" ] = true,
|
||||
[ "false" ] = false,
|
||||
[ "null" ] = nil,
|
||||
}
|
||||
|
||||
|
||||
local function next_char(str, idx, set, negate)
|
||||
for i = idx, #str do
|
||||
if set[str:sub(i, i)] ~= negate then
|
||||
return i
|
||||
end
|
||||
end
|
||||
return #str + 1
|
||||
end
|
||||
|
||||
|
||||
local function decode_error(str, idx, msg)
|
||||
local line_count = 1
|
||||
local col_count = 1
|
||||
for i = 1, idx - 1 do
|
||||
col_count = col_count + 1
|
||||
if str:sub(i, i) == "\n" then
|
||||
line_count = line_count + 1
|
||||
col_count = 1
|
||||
end
|
||||
end
|
||||
error( string.format("%s at line %d col %d", msg, line_count, col_count) )
|
||||
end
|
||||
|
||||
|
||||
local function codepoint_to_utf8(n)
|
||||
-- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa
|
||||
local f = math.floor
|
||||
if n <= 0x7f then
|
||||
return string.char(n)
|
||||
elseif n <= 0x7ff then
|
||||
return string.char(f(n / 64) + 192, n % 64 + 128)
|
||||
elseif n <= 0xffff then
|
||||
return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128)
|
||||
elseif n <= 0x10ffff then
|
||||
return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128,
|
||||
f(n % 4096 / 64) + 128, n % 64 + 128)
|
||||
end
|
||||
error( string.format("invalid unicode codepoint '%x'", n) )
|
||||
end
|
||||
|
||||
|
||||
local function parse_unicode_escape(s)
|
||||
local n1 = tonumber( s:sub(1, 4), 16 )
|
||||
local n2 = tonumber( s:sub(7, 10), 16 )
|
||||
-- Surrogate pair?
|
||||
if n2 then
|
||||
return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000)
|
||||
else
|
||||
return codepoint_to_utf8(n1)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
local function parse_string(str, i)
|
||||
local res = ""
|
||||
local j = i + 1
|
||||
local k = j
|
||||
|
||||
while j <= #str do
|
||||
local x = str:byte(j)
|
||||
|
||||
if x < 32 then
|
||||
decode_error(str, j, "control character in string")
|
||||
|
||||
elseif x == 92 then -- `\`: Escape
|
||||
res = res .. str:sub(k, j - 1)
|
||||
j = j + 1
|
||||
local c = str:sub(j, j)
|
||||
if c == "u" then
|
||||
local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1)
|
||||
or str:match("^%x%x%x%x", j + 1)
|
||||
or decode_error(str, j - 1, "invalid unicode escape in string")
|
||||
res = res .. parse_unicode_escape(hex)
|
||||
j = j + #hex
|
||||
else
|
||||
if not escape_chars[c] then
|
||||
decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string")
|
||||
end
|
||||
res = res .. escape_char_map_inv[c]
|
||||
end
|
||||
k = j + 1
|
||||
|
||||
elseif x == 34 then -- `"`: End of string
|
||||
res = res .. str:sub(k, j - 1)
|
||||
return res, j + 1
|
||||
end
|
||||
|
||||
j = j + 1
|
||||
end
|
||||
|
||||
decode_error(str, i, "expected closing quote for string")
|
||||
end
|
||||
|
||||
|
||||
local function parse_number(str, i)
|
||||
local x = next_char(str, i, delim_chars)
|
||||
local s = str:sub(i, x - 1)
|
||||
local n = tonumber(s)
|
||||
if not n then
|
||||
decode_error(str, i, "invalid number '" .. s .. "'")
|
||||
end
|
||||
return n, x
|
||||
end
|
||||
|
||||
|
||||
local function parse_literal(str, i)
|
||||
local x = next_char(str, i, delim_chars)
|
||||
local word = str:sub(i, x - 1)
|
||||
if not literals[word] then
|
||||
decode_error(str, i, "invalid literal '" .. word .. "'")
|
||||
end
|
||||
return literal_map[word], x
|
||||
end
|
||||
|
||||
|
||||
local function parse_array(str, i)
|
||||
local res = {}
|
||||
local n = 1
|
||||
i = i + 1
|
||||
while 1 do
|
||||
local x
|
||||
i = next_char(str, i, space_chars, true)
|
||||
-- Empty / end of array?
|
||||
if str:sub(i, i) == "]" then
|
||||
i = i + 1
|
||||
break
|
||||
end
|
||||
-- Read token
|
||||
x, i = parse(str, i)
|
||||
res[n] = x
|
||||
n = n + 1
|
||||
-- Next token
|
||||
i = next_char(str, i, space_chars, true)
|
||||
local chr = str:sub(i, i)
|
||||
i = i + 1
|
||||
if chr == "]" then break end
|
||||
if chr ~= "," then decode_error(str, i, "expected ']' or ','") end
|
||||
end
|
||||
return res, i
|
||||
end
|
||||
|
||||
|
||||
local function parse_object(str, i)
|
||||
local res = {}
|
||||
i = i + 1
|
||||
while 1 do
|
||||
local key, val
|
||||
i = next_char(str, i, space_chars, true)
|
||||
-- Empty / end of object?
|
||||
if str:sub(i, i) == "}" then
|
||||
i = i + 1
|
||||
break
|
||||
end
|
||||
-- Read key
|
||||
if str:sub(i, i) ~= '"' then
|
||||
decode_error(str, i, "expected string for key")
|
||||
end
|
||||
key, i = parse(str, i)
|
||||
-- Read ':' delimiter
|
||||
i = next_char(str, i, space_chars, true)
|
||||
if str:sub(i, i) ~= ":" then
|
||||
decode_error(str, i, "expected ':' after key")
|
||||
end
|
||||
i = next_char(str, i + 1, space_chars, true)
|
||||
-- Read value
|
||||
val, i = parse(str, i)
|
||||
-- Set
|
||||
res[key] = val
|
||||
-- Next token
|
||||
i = next_char(str, i, space_chars, true)
|
||||
local chr = str:sub(i, i)
|
||||
i = i + 1
|
||||
if chr == "}" then break end
|
||||
if chr ~= "," then decode_error(str, i, "expected '}' or ','") end
|
||||
end
|
||||
return res, i
|
||||
end
|
||||
|
||||
|
||||
local char_func_map = {
|
||||
[ '"' ] = parse_string,
|
||||
[ "0" ] = parse_number,
|
||||
[ "1" ] = parse_number,
|
||||
[ "2" ] = parse_number,
|
||||
[ "3" ] = parse_number,
|
||||
[ "4" ] = parse_number,
|
||||
[ "5" ] = parse_number,
|
||||
[ "6" ] = parse_number,
|
||||
[ "7" ] = parse_number,
|
||||
[ "8" ] = parse_number,
|
||||
[ "9" ] = parse_number,
|
||||
[ "-" ] = parse_number,
|
||||
[ "t" ] = parse_literal,
|
||||
[ "f" ] = parse_literal,
|
||||
[ "n" ] = parse_literal,
|
||||
[ "[" ] = parse_array,
|
||||
[ "{" ] = parse_object,
|
||||
}
|
||||
|
||||
|
||||
parse = function(str, idx)
|
||||
local chr = str:sub(idx, idx)
|
||||
local f = char_func_map[chr]
|
||||
if f then
|
||||
return f(str, idx)
|
||||
end
|
||||
decode_error(str, idx, "unexpected character '" .. chr .. "'")
|
||||
end
|
||||
|
||||
|
||||
function json.decode(str)
|
||||
if type(str) ~= "string" then
|
||||
error("expected argument of type string, got " .. type(str))
|
||||
end
|
||||
local res, idx = parse(str, next_char(str, 1, space_chars, true))
|
||||
idx = next_char(str, idx, space_chars, true)
|
||||
if idx <= #str then
|
||||
decode_error(str, idx, "trailing garbage")
|
||||
end
|
||||
return res
|
||||
end
|
||||
|
||||
|
||||
return json
|
||||
@ -1,10 +0,0 @@
|
||||
stdlib:
|
||||
help: stdlib for repotool
|
||||
files:
|
||||
- source: "lib/stdlib.sh"
|
||||
target: "%{user_lib_dir}/repotool/stdlib.%{user_ext}"
|
||||
git:
|
||||
help: git for repotool
|
||||
files:
|
||||
- source: "lib/git.sh"
|
||||
target: "%{user_lib_dir}/repotool/git.%{user_ext}"
|
||||
114
main.lua
Executable file
114
main.lua
Executable file
@ -0,0 +1,114 @@
|
||||
-- Add src/lib to package path
|
||||
local script_path = debug.getinfo(1).source:match("@(.+)") or arg[0]
|
||||
local script_dir = script_path:match("(.*/)")
|
||||
if not script_dir then
|
||||
script_dir = "./"
|
||||
end
|
||||
package.path = script_dir .. "lib/?.lua;" .. script_dir .. "src/?.lua;" .. script_dir .. "src/lib/?.lua;" .. package.path
|
||||
|
||||
local cli = require("cli")
|
||||
local json = require("json")
|
||||
|
||||
-- Load command modules
|
||||
local get_command = require("get_command")
|
||||
local worktree_command = require("worktree_command")
|
||||
local open_command = require("open_command")
|
||||
|
||||
-- Create the main command with subcommands
|
||||
local app = cli.cmd {
|
||||
'repotool',
|
||||
name = 'repotool',
|
||||
version = '0.1.0',
|
||||
desc = 'repo tool',
|
||||
subs = {
|
||||
-- Get command
|
||||
cli.cmd {
|
||||
'get', 'g',
|
||||
desc = 'gets repo if not found',
|
||||
term = cli.all {
|
||||
repo = cli.arg {
|
||||
'repo',
|
||||
desc = 'URL to repo',
|
||||
required = true,
|
||||
},
|
||||
ssh_user = cli.opt {
|
||||
'--ssh-user',
|
||||
desc = 'ssh user to clone with',
|
||||
vdesc = 'USER',
|
||||
},
|
||||
http_user = cli.opt {
|
||||
'--http-user',
|
||||
desc = 'http user to clone with',
|
||||
vdesc = 'USER',
|
||||
},
|
||||
http_pass = cli.opt {
|
||||
'--http-pass',
|
||||
desc = 'http pass to clone with',
|
||||
vdesc = 'PASS',
|
||||
},
|
||||
method = cli.opt {
|
||||
'--method', '-m',
|
||||
desc = 'the method to clone the repo with',
|
||||
vdesc = 'METHOD',
|
||||
},
|
||||
}:and_then(function(args)
|
||||
get_command({
|
||||
repo = args.repo,
|
||||
ssh_user = args.ssh_user or 'git',
|
||||
http_user = args.http_user or '',
|
||||
http_pass = args.http_pass or '',
|
||||
method = args.method or 'ssh',
|
||||
})
|
||||
return 0
|
||||
end),
|
||||
},
|
||||
-- Worktree command
|
||||
cli.cmd {
|
||||
'worktree', 'w', 'wt',
|
||||
desc = 'goes to or creates a worktree with the name for the repo you are in',
|
||||
term = cli.all {
|
||||
name = cli.arg {
|
||||
'name',
|
||||
desc = 'Name of the worktree',
|
||||
required = false,
|
||||
},
|
||||
list = cli.opt {
|
||||
'--list', '-l',
|
||||
desc = 'List existing worktrees for this repo',
|
||||
flag = true,
|
||||
},
|
||||
root = cli.opt {
|
||||
'--root', '-r',
|
||||
desc = 'Return the root directory of the original repo',
|
||||
flag = true,
|
||||
},
|
||||
}:and_then(function(args)
|
||||
-- Handle special cases: "list" and "ls" as commands
|
||||
local name = args.name
|
||||
if name == "list" or name == "ls" then
|
||||
args.list = true
|
||||
args.name = nil
|
||||
end
|
||||
|
||||
worktree_command({
|
||||
name = args.name,
|
||||
list = args.list,
|
||||
root = args.root,
|
||||
})
|
||||
return 0
|
||||
end),
|
||||
},
|
||||
-- Open command
|
||||
cli.cmd {
|
||||
'open', 'o',
|
||||
desc = 'open the current repository in web browser',
|
||||
term = cli.val():and_then(function()
|
||||
open_command({})
|
||||
return 0
|
||||
end),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- Run the app
|
||||
os.exit(app:run())
|
||||
19
package.json
19
package.json
@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "repotool",
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "make all"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@semantic-release/npm": "^12.0.0",
|
||||
"semantic-release": "^23.0.8",
|
||||
"tsx": "^4.7.2",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"packageManager": "yarn@4.1.1"
|
||||
}
|
||||
63
settings.yml
63
settings.yml
@ -1,63 +0,0 @@
|
||||
# All settings are optional (with their default values provided below), and
|
||||
# can also be set with an environment variable with the same name, capitalized
|
||||
# and prefixed by `BASHLY_` - for example: BASHLY_SOURCE_DIR
|
||||
#
|
||||
# When setting environment variables, you can use:
|
||||
# - "0", "false" or "no" to represent false
|
||||
# - "1", "true" or "yes" to represent true
|
||||
#
|
||||
# If you wish to change the path to this file, set the environment variable
|
||||
# BASHLY_SETTINGS_PATH.
|
||||
|
||||
# The path containing the bashly source files
|
||||
source_dir: src
|
||||
|
||||
# The path to bashly.yml
|
||||
config_path: "%{source_dir}/bashly.yml"
|
||||
|
||||
# The path to use for creating the bash script
|
||||
target_dir: .
|
||||
|
||||
# The path to use for common library files, relative to source_dir
|
||||
lib_dir: lib
|
||||
|
||||
# The path to use for command files, relative to source_dir
|
||||
# When set to nil (~), command files will be placed directly under source_dir
|
||||
# When set to any other string, command files will be placed under this
|
||||
# directory, and each command will get its own subdirectory
|
||||
commands_dir: ~
|
||||
|
||||
# Configure the bash options that will be added to the initialize function:
|
||||
# strict: true Bash strict mode (set -euo pipefail)
|
||||
# strict: false Only exit on errors (set -e)
|
||||
# strict: '' Do not add any 'set' directive
|
||||
# strict: <string> Add any other custom 'set' directive
|
||||
strict: false
|
||||
|
||||
# When true, the generated script will use tab indentation instead of spaces
|
||||
# (every 2 leading spaces will be converted to a tab character)
|
||||
tab_indent: false
|
||||
|
||||
# When true, the generated script will consider any argument in the form of
|
||||
# `-abc` as if it is `-a -b -c`.
|
||||
compact_short_flags: true
|
||||
|
||||
# Set to 'production' or 'development':
|
||||
# env: production Generate a smaller script, without file markers
|
||||
# env: development Generate with file markers
|
||||
env: development
|
||||
|
||||
# The extension to use when reading/writing partial script snippets
|
||||
partials_extension: sh
|
||||
|
||||
# Display various usage elements in color by providing the name of the color
|
||||
# function. The value for each property is a name of a function that is
|
||||
# available in your script, for example: `green` or `bold`.
|
||||
# You can run `bashly add colors` to add a standard colors library.
|
||||
# This option cannot be set via environment variables.
|
||||
usage_colors:
|
||||
caption: ~
|
||||
command: ~
|
||||
arg: ~
|
||||
flag: ~
|
||||
environment_variable: ~
|
||||
@ -27,34 +27,34 @@ _parse_json() {
|
||||
|
||||
_handle_response() {
|
||||
local response="$1"
|
||||
|
||||
|
||||
# Validate JSON
|
||||
if ! echo "$response" | jq . >/dev/null 2>&1; then
|
||||
# Not valid JSON, write to stderr
|
||||
echo "$response" >&2
|
||||
return
|
||||
fi
|
||||
|
||||
|
||||
# Check if response has an echo field
|
||||
local echo_msg=$(echo "$response" | jq -r '.echo // empty')
|
||||
if [[ -n "$echo_msg" ]]; then
|
||||
echo "$echo_msg"
|
||||
fi
|
||||
|
||||
|
||||
# Get hook name from response
|
||||
local hook_name=$(echo "$response" | jq -r '.hook // empty')
|
||||
|
||||
|
||||
# Handle before hook if hook name is provided
|
||||
if [[ -n "$hook_name" ]]; then
|
||||
_activate_hook "before_${hook_name}_cd" "$response"
|
||||
fi
|
||||
|
||||
|
||||
# Check if response has a cd field
|
||||
local cd_path=$(echo "$response" | jq -r '.cd // empty')
|
||||
if [[ -n "$cd_path" ]]; then
|
||||
cd "$cd_path"
|
||||
fi
|
||||
|
||||
|
||||
# Handle after hook if hook name is provided
|
||||
if [[ -n "$hook_name" ]]; then
|
||||
_activate_hook "after_${hook_name}_cd" "$response"
|
||||
@ -72,4 +72,4 @@ if [[ $exit_code != 0 ]]; then
|
||||
return $exit_code
|
||||
fi
|
||||
|
||||
_handle_response "$response"
|
||||
_handle_response "$response"
|
||||
@ -1,85 +0,0 @@
|
||||
name: repotool
|
||||
help: repo tool
|
||||
version: 0.1.0
|
||||
|
||||
environment_variables:
|
||||
- name: REPOTOOL_PATH
|
||||
default: $HOME/repo
|
||||
help: default path to clone to
|
||||
- name: DEBUG_LOG
|
||||
default: "0"
|
||||
help: set to 1 to enable debug logg
|
||||
|
||||
commands:
|
||||
- name: get
|
||||
alias: g
|
||||
help: gets repo if not found
|
||||
dependencies:
|
||||
git:
|
||||
command: ["git"]
|
||||
perl:
|
||||
command: ["perl"]
|
||||
jq:
|
||||
command: ["jq"]
|
||||
args:
|
||||
- name: repo
|
||||
required: true
|
||||
help: URL to repo
|
||||
flags:
|
||||
- long: --ssh-user
|
||||
help: ssh user to clone with.
|
||||
arg: "ssh_user"
|
||||
default: "git"
|
||||
- long: --http-user
|
||||
help: http user to clone with.
|
||||
arg: "http_user"
|
||||
default: ""
|
||||
- long: --http-pass
|
||||
help: http pass to clone with.
|
||||
arg: "http_pass"
|
||||
default: ""
|
||||
- long: --method
|
||||
short: -m
|
||||
help: the method to clone the repo with
|
||||
arg: "method"
|
||||
default: "ssh"
|
||||
allowed: ["ssh", "https", "http"]
|
||||
examples:
|
||||
- repo get tuxpa.in/a/repotool
|
||||
- name: worktree
|
||||
alias: [w, wt]
|
||||
help: get worktree path for current repo
|
||||
dependencies:
|
||||
git:
|
||||
command: ["git"]
|
||||
perl:
|
||||
command: ["perl"]
|
||||
jq:
|
||||
command: ["jq"]
|
||||
args:
|
||||
- name: name
|
||||
required: false
|
||||
help: Name of the worktree
|
||||
flags:
|
||||
- long: --list
|
||||
short: -l
|
||||
help: List existing worktrees for this repo
|
||||
- long: --root
|
||||
short: -r
|
||||
help: Return the root directory of the original repo
|
||||
examples:
|
||||
- repo worktree feature-branch
|
||||
- repo worktree -l
|
||||
- repo worktree -r
|
||||
- name: open
|
||||
alias: o
|
||||
help: open the current repository in web browser
|
||||
dependencies:
|
||||
git:
|
||||
command: ["git"]
|
||||
perl:
|
||||
command: ["perl"]
|
||||
jq:
|
||||
command: ["jq"]
|
||||
examples:
|
||||
- repo open
|
||||
59
src/fs.lua
Normal file
59
src/fs.lua
Normal file
@ -0,0 +1,59 @@
|
||||
local fs = {}
|
||||
|
||||
-- Check if a file exists
|
||||
function fs.file_exists(name)
|
||||
local f = io.open(name, "r")
|
||||
return f ~= nil and io.close(f)
|
||||
end
|
||||
|
||||
-- Check if a directory exists
|
||||
function fs.dir_exists(path)
|
||||
-- Try to open the directory
|
||||
local f = io.open(path, "r")
|
||||
if f then
|
||||
io.close(f)
|
||||
-- Check if it's actually a directory by trying to list it
|
||||
local handle = io.popen('test -d "' .. path .. '" && echo "yes" || echo "no"')
|
||||
local result = handle:read("*a"):gsub("\n", "")
|
||||
handle:close()
|
||||
return result == "yes"
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Create directory with parents
|
||||
function fs.mkdir_p(path)
|
||||
os.execute('mkdir -p "' .. path .. '"')
|
||||
end
|
||||
|
||||
-- Get the parent directory of a path
|
||||
function fs.dirname(path)
|
||||
return path:match("(.*/)[^/]+/?$") or "."
|
||||
end
|
||||
|
||||
-- Get the basename of a path
|
||||
function fs.basename(path)
|
||||
return path:match(".*/([^/]+)/?$") or path
|
||||
end
|
||||
|
||||
-- Join path components
|
||||
function fs.join(...)
|
||||
local parts = {...}
|
||||
local path = ""
|
||||
for i, part in ipairs(parts) do
|
||||
if i == 1 then
|
||||
path = part
|
||||
else
|
||||
if path:sub(-1) ~= "/" and part:sub(1, 1) ~= "/" then
|
||||
path = path .. "/" .. part
|
||||
elseif path:sub(-1) == "/" and part:sub(1, 1) == "/" then
|
||||
path = path .. part:sub(2)
|
||||
else
|
||||
path = path .. part
|
||||
end
|
||||
end
|
||||
end
|
||||
return path
|
||||
end
|
||||
|
||||
return fs
|
||||
97
src/get_command.lua
Normal file
97
src/get_command.lua
Normal file
@ -0,0 +1,97 @@
|
||||
local git = require("git")
|
||||
local json = require("json")
|
||||
local fs = require("fs")
|
||||
|
||||
local function get_command(args)
|
||||
-- Validate URL
|
||||
local repo_url = args.repo
|
||||
local url_type = git.valid_url(repo_url)
|
||||
if url_type == -1 then
|
||||
io.stderr:write(repo_url .. " is not a valid repo\n")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
-- Parse URL
|
||||
local domain, path = git.parse_url(repo_url)
|
||||
if not domain or not path then
|
||||
io.stderr:write("Failed to parse repository URL: " .. repo_url .. "\n")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
-- Get configuration
|
||||
local base_path = os.getenv("REPOTOOL_PATH") or os.getenv("HOME") .. "/repo"
|
||||
local method = args.method or "ssh"
|
||||
local ssh_user = args.ssh_user or "git"
|
||||
local http_user = args.http_user or ""
|
||||
local http_pass = args.http_pass or ""
|
||||
|
||||
-- Debug output
|
||||
local function lcat(text)
|
||||
if os.getenv("DEBUG_LOG") == "1" then
|
||||
io.stderr:write(text)
|
||||
end
|
||||
end
|
||||
|
||||
lcat(string.format([[
|
||||
found valid repo target
|
||||
|
||||
domain: %s
|
||||
path: %s
|
||||
|
||||
ssh_user: %s
|
||||
method: %s
|
||||
|
||||
http_user: %s
|
||||
http_pass: %s
|
||||
]], domain, path, ssh_user, method, http_user, http_pass))
|
||||
|
||||
-- Construct target directory
|
||||
local target_dir = base_path .. "/" .. domain .. "/" .. path
|
||||
|
||||
-- Create directory if it doesn't exist
|
||||
if not fs.dir_exists(target_dir) then
|
||||
fs.mkdir_p(target_dir)
|
||||
end
|
||||
|
||||
-- Change to target directory
|
||||
os.execute("cd '" .. target_dir .. "'")
|
||||
|
||||
-- Construct repo URL based on method
|
||||
local clone_url
|
||||
if method == "ssh" then
|
||||
clone_url = ssh_user .. "@" .. domain .. ":" .. path
|
||||
elseif method == "https" or method == "http" then
|
||||
-- TODO: support http_user and http_pass
|
||||
clone_url = method .. "://" .. domain .. "/" .. path .. ".git"
|
||||
else
|
||||
io.stderr:write("unrecognized clone method " .. method .. "\n")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
-- Check if we need to clone
|
||||
local cloned = "false"
|
||||
local git_dir = fs.join(target_dir, ".git")
|
||||
if not fs.dir_exists(git_dir) then
|
||||
-- Check if remote exists
|
||||
local check_cmd = "cd '" .. target_dir .. "' && git ls-remote '" .. clone_url .. "' >/dev/null 2>&1"
|
||||
if os.execute(check_cmd) == 0 then
|
||||
os.execute("git clone '" .. clone_url .. "' '" .. target_dir .. "' >&2")
|
||||
cloned = "true"
|
||||
else
|
||||
io.stderr:write("Could not find repo: " .. clone_url .. "\n")
|
||||
os.exit(1)
|
||||
end
|
||||
end
|
||||
|
||||
-- Output JSON
|
||||
print(json.encode({
|
||||
cd = target_dir,
|
||||
domain = domain,
|
||||
path = path,
|
||||
repo_url = clone_url,
|
||||
cloned = cloned,
|
||||
hook = "get"
|
||||
}))
|
||||
end
|
||||
|
||||
return get_command
|
||||
@ -1,114 +0,0 @@
|
||||
#!/bin/bash
|
||||
linspect
|
||||
|
||||
local resp
|
||||
resp=$(valid_url ${args[repo]})
|
||||
if [[ $resp == -1 ]]; then
|
||||
echo "${args[repo]} is not a valid repo"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local regex="${deps[perl]} -n -l -e"
|
||||
|
||||
local git="${deps[git]}"
|
||||
|
||||
local base_path=$REPOTOOL_PATH
|
||||
|
||||
|
||||
local ssh_user;
|
||||
# the ssh user to clone with
|
||||
|
||||
local http_user;
|
||||
local http_pass;
|
||||
|
||||
# Parse the URL to get domain and path
|
||||
parsed_json=$(parse_git_url "${args[repo]}" "$regex" "${deps[jq]}")
|
||||
domain=$(echo "$parsed_json" | ${deps[jq]} -r '.domain')
|
||||
path=$(echo "$parsed_json" | ${deps[jq]} -r '.path')
|
||||
|
||||
if [[ -z "$domain" ]] || [[ -z "$path" ]]; then
|
||||
echo "Failed to parse repository URL: ${args[repo]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -z "${args[--ssh-user]}" && -z "$ssh_user" ]]; then
|
||||
ssh_user=${args[--ssh-user]}
|
||||
fi
|
||||
|
||||
if [[ ! -z "${args[--http-user]}" && -z "$http_user" ]]; then
|
||||
http_user=${args[--http-user]}
|
||||
fi
|
||||
|
||||
if [[ ! -z "${args[--http-pass]}" && -z "$http_pass" ]]; then
|
||||
http_pass=${args[--http-pass]}
|
||||
fi
|
||||
|
||||
|
||||
if [[ -z "$method" ]]; then
|
||||
method=${args[--method]}
|
||||
fi
|
||||
|
||||
lcat << EOF
|
||||
found valid repo target
|
||||
|
||||
domain: $domain
|
||||
path: $path
|
||||
|
||||
ssh_user: $ssh_user
|
||||
method: $method
|
||||
|
||||
http_user: $http_user
|
||||
http_pass: $http_pass
|
||||
EOF
|
||||
|
||||
|
||||
local target_dir="$base_path/$domain/$path"
|
||||
|
||||
if [[ ! -d $target_dir ]]; then
|
||||
mkdir -p $target_dir
|
||||
fi
|
||||
|
||||
cd $target_dir
|
||||
|
||||
local repo_url=""
|
||||
|
||||
case $method in
|
||||
ssh)
|
||||
repo_url="$ssh_user@$domain:$path"
|
||||
;;
|
||||
https | http)
|
||||
# TODO: support http_user and http_pass
|
||||
repo_url="$method://$domain/$path.git"
|
||||
;;
|
||||
*)
|
||||
echo "unrecognized clone method $method"
|
||||
exit 1
|
||||
esac
|
||||
|
||||
|
||||
|
||||
local cloned="false"
|
||||
# we check if we have cloned the repo via the if the .git folder exists
|
||||
if [[ ! -d .git ]]; then
|
||||
# check if the remote actually exists
|
||||
$git ls-remote $repo_url > /dev/null && RC=$? || RC=$?
|
||||
if [[ $RC == 0 ]]; then
|
||||
$git clone $repo_url $target_dir >&2
|
||||
cloned="true"
|
||||
else
|
||||
echo "Could not find repo: $repo_url"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Output in JSON format
|
||||
${deps[jq]} -n \
|
||||
--arg cd "$target_dir" \
|
||||
--arg domain "$domain" \
|
||||
--arg path "$path" \
|
||||
--arg repo_url "$repo_url" \
|
||||
--arg cloned "$cloned" \
|
||||
--arg hook "get" \
|
||||
'{cd: $cd, domain: $domain, path: $path, repo_url: $repo_url, cloned: $cloned, hook: $hook}'
|
||||
|
||||
exit 0
|
||||
145
src/git.lua
Normal file
145
src/git.lua
Normal file
@ -0,0 +1,145 @@
|
||||
local git = {}
|
||||
|
||||
-- Parse a git URL into domain and path components
|
||||
function git.parse_url(url)
|
||||
local domain, path
|
||||
|
||||
-- Check if it's an HTTP(S) URL
|
||||
domain, path = url:match("^https?://[^/]*@?([^/]+)/(.+)%.git$")
|
||||
if not domain then
|
||||
domain, path = url:match("^https?://[^/]*@?([^/]+)/(.+)$")
|
||||
end
|
||||
|
||||
-- Check if it's an SSH URL (git@host:path or ssh://...)
|
||||
if not domain then
|
||||
domain, path = url:match("^[^@]+@([^:]+):(.+)%.git$")
|
||||
end
|
||||
if not domain then
|
||||
domain, path = url:match("^[^@]+@([^:]+):(.+)$")
|
||||
end
|
||||
if not domain then
|
||||
domain, path = url:match("^ssh://[^@]*@?([^/]+)/(.+)%.git$")
|
||||
end
|
||||
if not domain then
|
||||
domain, path = url:match("^ssh://[^@]*@?([^/]+)/(.+)$")
|
||||
end
|
||||
|
||||
-- Check if it's a bare domain path (e.g., gfx.cafe/oku/trade)
|
||||
if not domain then
|
||||
domain, path = url:match("^([^/]+%.%w+)/(.+)$")
|
||||
end
|
||||
|
||||
return domain, path
|
||||
end
|
||||
|
||||
-- Get domain and path from current git repository's origin
|
||||
function git.parse_origin()
|
||||
local handle = io.popen("git config --get remote.origin.url 2>/dev/null")
|
||||
local origin_url = handle:read("*a"):gsub("\n", "")
|
||||
handle:close()
|
||||
|
||||
if origin_url == "" then
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
return git.parse_url(origin_url)
|
||||
end
|
||||
|
||||
-- Validate if a URL is a valid git repository URL
|
||||
function git.valid_url(url)
|
||||
if url:match("^https?://") then
|
||||
return 1 -- HTTP(S)
|
||||
elseif url:match("^[^:]+@[^:]+:") or url:match("^ssh://") then
|
||||
return 2 -- SSH
|
||||
elseif url:match("^[^/]+%.%w+/[^/]+") then
|
||||
return 3 -- Bare domain path
|
||||
else
|
||||
return -1 -- Invalid
|
||||
end
|
||||
end
|
||||
|
||||
-- Execute command and return output
|
||||
function git.execute(cmd)
|
||||
local handle = io.popen(cmd .. " 2>&1")
|
||||
local result = handle:read("*a")
|
||||
local success = handle:close()
|
||||
return result, success
|
||||
end
|
||||
|
||||
-- Check if we're in a git repository
|
||||
function git.in_repo()
|
||||
local _, success = git.execute("git rev-parse --git-dir")
|
||||
return success
|
||||
end
|
||||
|
||||
-- Get repository root
|
||||
function git.get_repo_root()
|
||||
local output, success = git.execute("git rev-parse --show-toplevel")
|
||||
if success then
|
||||
return output:gsub("\n", "")
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Get git common directory (for worktree detection)
|
||||
function git.get_common_dir()
|
||||
local output, success = git.execute("git rev-parse --git-common-dir")
|
||||
if success then
|
||||
return output:gsub("\n", "")
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Get list of all worktrees with their properties
|
||||
function git.worktree_list()
|
||||
local worktrees = {}
|
||||
local handle = io.popen("git worktree list --porcelain")
|
||||
local current_worktree = nil
|
||||
|
||||
for line in handle:lines() do
|
||||
local worktree_path = line:match("^worktree (.+)$")
|
||||
if worktree_path then
|
||||
-- Save previous worktree if any
|
||||
if current_worktree then
|
||||
table.insert(worktrees, current_worktree)
|
||||
end
|
||||
-- Start new worktree
|
||||
current_worktree = {
|
||||
path = worktree_path,
|
||||
head = nil,
|
||||
branch = nil,
|
||||
bare = false,
|
||||
detached = false,
|
||||
locked = false,
|
||||
prunable = false
|
||||
}
|
||||
elseif current_worktree then
|
||||
-- Parse other worktree properties
|
||||
local head = line:match("^HEAD (.+)$")
|
||||
if head then
|
||||
current_worktree.head = head
|
||||
elseif line:match("^branch (.+)$") then
|
||||
current_worktree.branch = line:match("^branch (.+)$")
|
||||
elseif line == "bare" then
|
||||
current_worktree.bare = true
|
||||
elseif line == "detached" then
|
||||
current_worktree.detached = true
|
||||
elseif line:match("^locked") then
|
||||
current_worktree.locked = true
|
||||
elseif line == "prunable gitdir file points to non-existent location" then
|
||||
current_worktree.prunable = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Don't forget the last worktree
|
||||
if current_worktree then
|
||||
table.insert(worktrees, current_worktree)
|
||||
end
|
||||
|
||||
handle:close()
|
||||
|
||||
return worktrees
|
||||
end
|
||||
|
||||
return git
|
||||
@ -1,47 +0,0 @@
|
||||
|
||||
|
||||
# ((git|ssh|http(s)?)?|(git@[\w\.]+))(:(//)?)([\w\.@\:/\-~]+)(\.git)?(/)?
|
||||
valid_url()
|
||||
{
|
||||
# matches https://<domain.tld>/<path>
|
||||
if [[ "$1" =~ ^https?://.*\..*/.*$ ]]; then
|
||||
echo '1'
|
||||
return 0
|
||||
fi
|
||||
# matches <user>@<domain.*?>:<path>
|
||||
if [[ "$1" =~ ^(.*?)(:|/)(.+)(\/.+)*$ ]]; then
|
||||
echo '2'
|
||||
return 0
|
||||
fi
|
||||
echo '-1'
|
||||
return 0
|
||||
}
|
||||
|
||||
lcat()
|
||||
{
|
||||
if [[ -z "$DEBUG_LOG" || "$DEBUG_LOG" == 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
cat $@ >&2
|
||||
}
|
||||
|
||||
lecho()
|
||||
{
|
||||
if [[ -z "$DEBUG_LOG" || "$DEBUG_LOG" == 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo $@ >&2
|
||||
}
|
||||
|
||||
linspect()
|
||||
{
|
||||
if [[ -z "$DEBUG_LOG" || "$DEBUG_LOG" == 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
inspect_args>&2
|
||||
}
|
||||
|
||||
|
||||
|
||||
64
src/open_command.lua
Normal file
64
src/open_command.lua
Normal file
@ -0,0 +1,64 @@
|
||||
local git = require("git")
|
||||
local json = require("json")
|
||||
|
||||
local function open_command(args)
|
||||
-- Check if we're in a git repository
|
||||
if not git.in_repo() then
|
||||
io.stderr:write("Error: Not in a git repository\n")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
-- Get the remote URL
|
||||
local handle = io.popen("git ls-remote --get-url")
|
||||
local raw_url = handle:read("*a"):gsub("\n", "")
|
||||
handle:close()
|
||||
|
||||
if raw_url == "" then
|
||||
io.stderr:write("Error: No remote URL found\n")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
-- Parse the URL
|
||||
local domain, path = git.parse_url(raw_url)
|
||||
if not domain or not path then
|
||||
io.stderr:write("Error: Unable to parse repository URL: " .. raw_url .. "\n")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
-- Construct HTTPS URL
|
||||
local https_url = "https://" .. domain .. "/" .. path
|
||||
|
||||
-- Detect platform and open URL
|
||||
local open_cmd
|
||||
local result = os.execute("command -v xdg-open >/dev/null 2>&1")
|
||||
if result == true or result == 0 then
|
||||
-- Linux
|
||||
open_cmd = "xdg-open"
|
||||
else
|
||||
result = os.execute("command -v open >/dev/null 2>&1")
|
||||
if result == true or result == 0 then
|
||||
-- macOS
|
||||
open_cmd = "open"
|
||||
else
|
||||
result = os.execute("command -v start >/dev/null 2>&1")
|
||||
if result == true or result == 0 then
|
||||
-- Windows
|
||||
open_cmd = "start"
|
||||
else
|
||||
io.stderr:write("Error: Unable to detect platform open command\n")
|
||||
os.exit(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Open the URL
|
||||
os.execute(open_cmd .. " '" .. https_url .. "' 2>/dev/null")
|
||||
|
||||
-- Output JSON
|
||||
print(json.encode({
|
||||
echo = "Opening " .. https_url .. " in browser...",
|
||||
hook = "open"
|
||||
}))
|
||||
end
|
||||
|
||||
return open_command
|
||||
@ -1,49 +0,0 @@
|
||||
linspect
|
||||
|
||||
# Check if we're in a git repository
|
||||
if ! git rev-parse --git-dir > /dev/null 2>&1; then
|
||||
echo "Error: Not in a git repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get the remote URL
|
||||
raw_url=$(git ls-remote --get-url)
|
||||
if [[ -z "$raw_url" ]]; then
|
||||
echo "Error: No remote URL found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse the URL to get domain and path
|
||||
local regex="${deps[perl]} -n -l -e"
|
||||
parsed_json=$(parse_git_url "$raw_url" "$regex" "${deps[jq]}")
|
||||
domain=$(echo "$parsed_json" | ${deps[jq]} -r '.domain')
|
||||
path=$(echo "$parsed_json" | ${deps[jq]} -r '.path')
|
||||
|
||||
if [[ -z "$domain" ]] || [[ -z "$path" ]]; then
|
||||
echo "Error: Unable to parse repository URL: $raw_url" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Construct the HTTPS URL
|
||||
https_url="https://${domain}/${path}"
|
||||
|
||||
# Detect the platform and open the URL
|
||||
if command -v xdg-open &> /dev/null; then
|
||||
# Linux
|
||||
xdg-open "$https_url" 2>/dev/null
|
||||
elif command -v open &> /dev/null; then
|
||||
# macOS
|
||||
open "$https_url" 2>/dev/null
|
||||
elif command -v start &> /dev/null; then
|
||||
# Windows
|
||||
start "$https_url" 2>/dev/null
|
||||
else
|
||||
echo "Error: Unable to detect platform open command" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Return JSON with echo message
|
||||
${deps[jq]} -n \
|
||||
--arg echo "Opening $https_url in browser..." \
|
||||
--arg hook "open" \
|
||||
'{echo: $echo, hook: $hook}'
|
||||
139
src/worktree_command.lua
Normal file
139
src/worktree_command.lua
Normal file
@ -0,0 +1,139 @@
|
||||
local git = require("git")
|
||||
local json = require("json")
|
||||
local fs = require("fs")
|
||||
|
||||
local function worktree_command(args)
|
||||
-- Check if we're in a git repository
|
||||
if not git.in_repo() then
|
||||
io.stderr:write("Error: Not in a git repository\n")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
-- Get repository root
|
||||
local repo_root = git.get_repo_root()
|
||||
|
||||
-- Handle --root flag
|
||||
if args.root then
|
||||
-- Check if we're in a worktree
|
||||
local git_common_dir = git.get_common_dir()
|
||||
local cd_path = repo_root
|
||||
|
||||
if git_common_dir ~= ".git" and git_common_dir ~= "" then
|
||||
-- We're in a worktree, get the main repo path
|
||||
cd_path = git_common_dir:match("(.+)/.git")
|
||||
end
|
||||
|
||||
print(json.encode({cd = cd_path, hook = "worktree"}))
|
||||
return
|
||||
end
|
||||
|
||||
-- Parse origin URL
|
||||
local domain, path = git.parse_origin()
|
||||
if not domain or not path then
|
||||
io.stderr:write("Error: Unable to parse repository origin URL\n")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
-- Calculate worktree base directory
|
||||
local repotool_path = os.getenv("REPOTOOL_PATH") or os.getenv("HOME") .. "/repo"
|
||||
local worktree_base = repotool_path .. "/worktree/" .. domain .. "/" .. path
|
||||
|
||||
-- Handle --list flag
|
||||
if args.list then
|
||||
-- Get all worktrees using the git library
|
||||
local worktrees = git.worktree_list()
|
||||
|
||||
-- Filter and display worktrees under our worktree base
|
||||
io.stderr:write("Worktrees for " .. domain .. "/" .. path .. ":\n")
|
||||
local found_any = false
|
||||
|
||||
local any_prunable = false
|
||||
for _, wt in ipairs(worktrees) do
|
||||
if wt.path == repo_root then
|
||||
else
|
||||
found_any = true
|
||||
local wt_name = wt.path:match(".*/([^/]+)$")
|
||||
local exists = fs.dir_exists(wt.path)
|
||||
|
||||
local status = ""
|
||||
if not exists then
|
||||
if wt.prunable then
|
||||
any_prunable = true
|
||||
status = " (prunable)"
|
||||
else
|
||||
status = " (missing)"
|
||||
end
|
||||
elseif wt.locked then
|
||||
status = " (locked)"
|
||||
elseif wt.detached then
|
||||
status = " (detached)"
|
||||
end
|
||||
io.stderr:write(" - " .. wt_name .. status .. "\n")
|
||||
end
|
||||
end
|
||||
|
||||
if any_prunable then
|
||||
io.stderr:write("Run 'git worktree prune' to remove prunable worktrees\n")
|
||||
end
|
||||
|
||||
if not found_any then
|
||||
io.stderr:write(" No worktrees found\n")
|
||||
end
|
||||
|
||||
print(json.encode({hook = "worktree.list"}))
|
||||
return
|
||||
end
|
||||
|
||||
-- Get worktree name from arguments
|
||||
local worktree_name = args.name
|
||||
|
||||
-- Check if name is provided
|
||||
if not worktree_name or worktree_name == "" then
|
||||
io.stderr:write([[Error: Missing required argument: name
|
||||
Run 'repo worktree --help' for usage information
|
||||
]])
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
-- Construct worktree path
|
||||
local worktree_path = worktree_base .. "/" .. worktree_name
|
||||
|
||||
-- Check if a prunable worktree exists at this path
|
||||
local check_prunable = io.popen("git worktree list | grep '" .. worktree_path .. ".*prunable'")
|
||||
if check_prunable:read("*a") ~= "" then
|
||||
io.stderr:write("Found prunable worktree at " .. worktree_path .. ", cleaning up...\n")
|
||||
os.execute("git worktree prune")
|
||||
end
|
||||
check_prunable:close()
|
||||
|
||||
-- Check if worktree already exists
|
||||
local created = "false"
|
||||
local check_exists = io.popen("git worktree list | grep '" .. worktree_path .. "'")
|
||||
if check_exists:read("*a") == "" then
|
||||
-- Create parent directories if they don't exist
|
||||
fs.mkdir_p(fs.dirname(worktree_path))
|
||||
|
||||
-- Create the worktree (try different methods)
|
||||
local success = os.execute("git worktree add '" .. worktree_path .. "' -b '" .. worktree_name .. "' 2>/dev/null") == 0
|
||||
if not success then
|
||||
success = os.execute("git worktree add '" .. worktree_path .. "' '" .. worktree_name .. "' 2>/dev/null") == 0
|
||||
end
|
||||
if not success then
|
||||
os.execute("git worktree add '" .. worktree_path .. "'")
|
||||
end
|
||||
created = "true"
|
||||
end
|
||||
check_exists:close()
|
||||
|
||||
-- Output JSON
|
||||
print(json.encode({
|
||||
cd = worktree_path,
|
||||
domain = domain,
|
||||
path = path,
|
||||
worktree_name = worktree_name,
|
||||
created = created,
|
||||
hook = "worktree"
|
||||
}))
|
||||
end
|
||||
|
||||
return worktree_command
|
||||
@ -1,143 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Get the current directory (should be inside a git repo)
|
||||
if ! git rev-parse --git-dir > /dev/null 2>&1; then
|
||||
echo "Error: Not in a git repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get the repository root
|
||||
repo_root=$(git rev-parse --show-toplevel)
|
||||
|
||||
# Handle --root flag
|
||||
if [[ "${args[--root]}" == "1" ]]; then
|
||||
# Check if we're in a worktree
|
||||
git_common_dir=$(git rev-parse --git-common-dir 2>/dev/null)
|
||||
if [[ "$git_common_dir" != ".git" && -d "$git_common_dir" ]]; then
|
||||
# We're in a worktree, get the main repo path
|
||||
main_repo=$(dirname "$git_common_dir")
|
||||
${deps[jq]} -n --arg cd "$main_repo" --arg hook "worktree" '{cd: $cd, hook: $hook}'
|
||||
else
|
||||
# We're in the main repo already
|
||||
${deps[jq]} -n --arg cd "$repo_root" --arg hook "worktree" '{cd: $cd, hook: $hook}'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Parse the origin URL to get domain and path
|
||||
local regex="${deps[perl]} -n -l -e"
|
||||
parsed_json=$(parse_git_origin "$regex" "${deps[jq]}")
|
||||
domain=$(echo "$parsed_json" | ${deps[jq]} -r '.domain')
|
||||
path=$(echo "$parsed_json" | ${deps[jq]} -r '.path')
|
||||
|
||||
if [[ -z "$domain" ]] || [[ -z "$path" ]]; then
|
||||
echo "Error: Unable to parse repository origin URL" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Calculate the worktree base directory
|
||||
worktree_base="$REPOTOOL_PATH/worktree/$domain/$path"
|
||||
|
||||
# Check if name argument is "list" or "ls"
|
||||
if [[ "${args[name]}" == "list" ]] || [[ "${args[name]}" == "ls" ]]; then
|
||||
args[--list]="1"
|
||||
fi
|
||||
|
||||
# Handle --list flag
|
||||
if [[ "${args[--list]}" == "1" ]]; then
|
||||
echo "Worktrees for $domain/$path:" >&2
|
||||
|
||||
# Get all worktrees from git using porcelain format
|
||||
local found_any=false
|
||||
local wt_path=""
|
||||
local is_prunable=false
|
||||
|
||||
while IFS= read -r line; do
|
||||
# Parse git worktree list --porcelain output
|
||||
if [[ "$line" =~ ^worktree[[:space:]](.+)$ ]]; then
|
||||
# Process previous worktree if any
|
||||
if [[ -n "$wt_path" ]] && [[ "$wt_path" == "$worktree_base/"* ]]; then
|
||||
local wt_name=$(basename "$wt_path")
|
||||
if [[ -d "$wt_path" ]]; then
|
||||
echo " - $wt_name" >&2
|
||||
else
|
||||
if [[ "$is_prunable" == "true" ]]; then
|
||||
echo " - $wt_name (missing - prunable)" >&2
|
||||
else
|
||||
echo " - $wt_name (missing)" >&2
|
||||
fi
|
||||
fi
|
||||
found_any=true
|
||||
fi
|
||||
|
||||
# Start new worktree
|
||||
wt_path="${BASH_REMATCH[1]}"
|
||||
is_prunable=false
|
||||
elif [[ "$line" == "prunable gitdir file points to non-existent location" ]]; then
|
||||
is_prunable=true
|
||||
fi
|
||||
done < <(git worktree list --porcelain)
|
||||
|
||||
# Process the last worktree
|
||||
if [[ -n "$wt_path" ]] && [[ "$wt_path" == "$worktree_base/"* ]]; then
|
||||
local wt_name=$(basename "$wt_path")
|
||||
if [[ -d "$wt_path" ]]; then
|
||||
echo " - $wt_name" >&2
|
||||
else
|
||||
if [[ "$is_prunable" == "true" ]]; then
|
||||
echo " - $wt_name (missing - prunable)" >&2
|
||||
else
|
||||
echo " - $wt_name (missing)" >&2
|
||||
fi
|
||||
fi
|
||||
found_any=true
|
||||
fi
|
||||
|
||||
if [[ "$found_any" == "false" ]]; then
|
||||
echo " No worktrees found" >&2
|
||||
fi
|
||||
|
||||
# Return hook field only (no cd field) so we don't change dirs
|
||||
${deps[jq]} -n --arg hook "worktree.list" '{hook: $hook}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get the worktree name from arguments
|
||||
worktree_name="${args[name]}"
|
||||
|
||||
# Check if name is provided (required for normal operation)
|
||||
if [[ -z "$worktree_name" ]]; then
|
||||
echo "Error: Missing required argument: name" >&2
|
||||
echo "Run 'repo worktree --help' for usage information" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Construct the worktree path
|
||||
worktree_path="$worktree_base/$worktree_name"
|
||||
|
||||
# Check if a prunable worktree exists at this path
|
||||
if git worktree list | grep -q "$worktree_path.*prunable"; then
|
||||
echo "Found prunable worktree at $worktree_path, cleaning up..." >&2
|
||||
git worktree prune
|
||||
fi
|
||||
|
||||
# Check if worktree already exists
|
||||
created="false"
|
||||
if ! git worktree list | grep -q "$worktree_path"; then
|
||||
# Create parent directories if they don't exist
|
||||
mkdir -p "$(dirname "$worktree_path")"
|
||||
|
||||
# Create the worktree
|
||||
git worktree add "$worktree_path" -b "$worktree_name" 2>/dev/null || git worktree add "$worktree_path" "$worktree_name" 2>/dev/null || git worktree add "$worktree_path"
|
||||
created="true"
|
||||
fi
|
||||
|
||||
# Output in JSON format
|
||||
${deps[jq]} -n \
|
||||
--arg cd "$worktree_path" \
|
||||
--arg domain "$domain" \
|
||||
--arg path "$path" \
|
||||
--arg worktree_name "$worktree_name" \
|
||||
--arg created "$created" \
|
||||
--arg hook "worktree" \
|
||||
'{cd: $cd, domain: $domain, path: $path, worktree_name: $worktree_name, created: $created, hook: $hook}'
|
||||
Loading…
Reference in New Issue
Block a user