Compare commits
3 Commits
2e8dafded7
...
97f8909001
| Author | SHA1 | Date | |
|---|---|---|---|
| 97f8909001 | |||
| 9470d7c36b | |||
| 46d7fbab1e |
22
Makefile
22
Makefile
@ -1,22 +1,20 @@
|
|||||||
.PHONY: all install
|
.PHONY: all install
|
||||||
|
|
||||||
SOURCES_LIBS:=$(shell find lib -type f)
|
|
||||||
SOURCES_SRC:=$(shell find src -type f )
|
SOURCES_SRC:=$(shell find src -type f )
|
||||||
|
LIBS_SRC:=$(shell find lib -type f )
|
||||||
REPOTOOL_PATH ?= ${HOME}/repo
|
REPOTOOL_PATH ?= ${HOME}/repo
|
||||||
|
|
||||||
|
|
||||||
all: dist/repotool
|
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/
|
mkdir -p ${REPOTOOL_PATH}/.bin/
|
||||||
install dist/repotool repotool.zsh repotool.plugin.zsh ${REPOTOOL_PATH}/.bin/
|
mkdir -p ${REPOTOOL_PATH}/.shell/
|
||||||
|
install dist/repotool ${REPOTOOL_PATH}/.bin/
|
||||||
src/lib/repotool/stdlib.sh: $(SOURCES_LIBS)
|
install shell/zsh/repotool.zsh shell/zsh/repotool.plugin.zsh ${REPOTOOL_PATH}/.shell/
|
||||||
bashly add --source . stdlib -f
|
|
||||||
|
|
||||||
dist/repotool: $(SOURCES_SRC)
|
|
||||||
mkdir -p dist
|
|
||||||
bashly generate
|
|
||||||
mv repotool dist
|
|
||||||
|
|
||||||
|
|
||||||
|
dist/repotool: $(SOURCES_SRC) $(LIBS_SRC) main.lua
|
||||||
|
@mkdir -p 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,
|
||||||
|
}
|
||||||
82
lib/git.sh
Normal file
82
lib/git.sh
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Parse a git URL into domain and path components
|
||||||
|
# Usage: parse_git_url <url> <perl_regex_command> <jq_command>
|
||||||
|
# Returns: JSON with domain and path fields
|
||||||
|
parse_git_url() {
|
||||||
|
local url="$1"
|
||||||
|
local regex="$2"
|
||||||
|
local jq="$3"
|
||||||
|
|
||||||
|
local domain=""
|
||||||
|
local path=""
|
||||||
|
local output
|
||||||
|
|
||||||
|
# Check if it's an HTTP(S) URL
|
||||||
|
if [[ "$url" =~ ^https?:// ]]; then
|
||||||
|
output=($(echo "$url" | $regex '/^https?:\/\/(?:.*?:)?(.*\..*?)\/(.*?)(\.git)?$/ && print "$1 $2" ' -))
|
||||||
|
if [[ ${#output[@]} -eq 2 ]]; then
|
||||||
|
domain=${output[0]}
|
||||||
|
path=${output[1]}
|
||||||
|
fi
|
||||||
|
# Check if it's an SSH URL
|
||||||
|
elif [[ "$url" =~ ^[^:]+@[^:]+: ]] || [[ "$url" =~ ^ssh:// ]]; then
|
||||||
|
output=($(echo "$url" | $regex '/^(?:.*?@)?(.*\..*?)(?::|\/)(.*?)(\.git)?$/ && print "$1 $2" ' -))
|
||||||
|
if [[ ${#output[@]} -eq 2 ]]; then
|
||||||
|
domain=${output[0]}
|
||||||
|
path=${output[1]}
|
||||||
|
fi
|
||||||
|
# Check if it's a bare domain path (e.g., gfx.cafe/oku/trade)
|
||||||
|
else
|
||||||
|
output=($(echo "$url" | $regex '/^(.*\..*?)\/(.*?)(\.git)?$/ && print "$1 $2" ' -))
|
||||||
|
if [[ ${#output[@]} -eq 2 ]]; then
|
||||||
|
domain=${output[0]}
|
||||||
|
path=${output[1]}
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Return JSON
|
||||||
|
$jq -n --arg domain "$domain" --arg path "$path" '{domain: $domain, path: $path}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get domain and path from current git repository's origin
|
||||||
|
# Usage: parse_git_origin <perl_regex_command> <jq_command>
|
||||||
|
# Returns: JSON with domain and path fields
|
||||||
|
parse_git_origin() {
|
||||||
|
local regex="$1"
|
||||||
|
local jq="$2"
|
||||||
|
|
||||||
|
# Get origin URL
|
||||||
|
local origin_url
|
||||||
|
origin_url=$(git config --get remote.origin.url)
|
||||||
|
|
||||||
|
if [[ -z "$origin_url" ]]; then
|
||||||
|
$jq -n '{domain: "", path: ""}'
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse the URL
|
||||||
|
parse_git_url "$origin_url" "$regex" "$jq"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate if a URL is a valid git repository URL
|
||||||
|
# Usage: valid_url <url>
|
||||||
|
# Returns: 1 for HTTP(S), 2 for SSH, 3 for bare domain paths, -1 for invalid
|
||||||
|
valid_url() {
|
||||||
|
local url="$1"
|
||||||
|
|
||||||
|
if [[ "$url" =~ ^https?:// ]]; then
|
||||||
|
echo "1"
|
||||||
|
return 0
|
||||||
|
elif [[ "$url" =~ ^[^:]+@[^:]+: ]] || [[ "$url" =~ ^ssh:// ]]; then
|
||||||
|
echo "2"
|
||||||
|
return 0
|
||||||
|
elif [[ "$url" =~ ^[a-zA-Z0-9.-]+\.[a-zA-Z]+/[^/]+ ]]; then
|
||||||
|
# Bare domain path like gfx.cafe/oku/trade
|
||||||
|
echo "3"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo "-1"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
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,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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
stdlib:
|
|
||||||
help: stdlib for repotool
|
|
||||||
files:
|
|
||||||
- source: "lib/stdlib.sh"
|
|
||||||
target: "%{user_lib_dir}/repotool/stdlib.%{user_ext}"
|
|
||||||
133
main.lua
Executable file
133
main.lua
Executable file
@ -0,0 +1,133 @@
|
|||||||
|
-- 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 = require("worktree")
|
||||||
|
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 with subcommands
|
||||||
|
cli.cmd {
|
||||||
|
'worktree', 'w', 'wt',
|
||||||
|
desc = 'manage git worktrees',
|
||||||
|
subs = {
|
||||||
|
-- List subcommand
|
||||||
|
cli.cmd {
|
||||||
|
'list', 'ls',
|
||||||
|
desc = 'list existing worktrees for this repo',
|
||||||
|
term = cli.val():and_then(function()
|
||||||
|
worktree.handle_list()
|
||||||
|
return 0
|
||||||
|
end),
|
||||||
|
},
|
||||||
|
-- Get subcommand
|
||||||
|
cli.cmd {
|
||||||
|
'get',"new","create","n","c",
|
||||||
|
desc = 'create or go to a worktree',
|
||||||
|
term = cli.all {
|
||||||
|
name = cli.arg {
|
||||||
|
'name',
|
||||||
|
desc = 'Name of the worktree',
|
||||||
|
required = true,
|
||||||
|
},
|
||||||
|
}:and_then(function(args)
|
||||||
|
worktree.handle_get(args.name)
|
||||||
|
return 0
|
||||||
|
end),
|
||||||
|
},
|
||||||
|
-- Root subcommand
|
||||||
|
cli.cmd {
|
||||||
|
'root', 'back', 'r', 'return',
|
||||||
|
desc = 'return to the root directory of the original repo',
|
||||||
|
term = cli.val():and_then(function()
|
||||||
|
worktree.handle_root()
|
||||||
|
return 0
|
||||||
|
end),
|
||||||
|
},
|
||||||
|
-- Remove subcommand
|
||||||
|
cli.cmd {
|
||||||
|
'remove', 'rm', 'delete', 'del',
|
||||||
|
desc = 'remove a worktree',
|
||||||
|
term = cli.all {
|
||||||
|
name = cli.arg {
|
||||||
|
'name',
|
||||||
|
desc = 'Name of the worktree to remove',
|
||||||
|
required = true,
|
||||||
|
},
|
||||||
|
}:and_then(function(args)
|
||||||
|
worktree.handle_remove(args.name)
|
||||||
|
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"
|
|
||||||
}
|
|
||||||
51
repotool.zsh
51
repotool.zsh
@ -1,51 +0,0 @@
|
|||||||
#!/usr/bin/env zsh
|
|
||||||
|
|
||||||
[[ -z "$REPOTOOL_PATH" ]] && export REPOTOOL_PATH="$HOME/repo"
|
|
||||||
|
|
||||||
|
|
||||||
_activate_hook() {
|
|
||||||
if [[ -f "$REPOTOOL_PATH/.hooks/$1.zsh" ]]; then
|
|
||||||
. "$REPOTOOL_PATH/.hooks/$1.zsh" $2
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if [[ -f "$REPOTOOL_PATH/.hooks/$1.sh" ]]; then
|
|
||||||
. "$REPOTOOL_PATH/.hooks/$1.sh" $2
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if [[ -f "$REPOTOOL_PATH/.hooks/$1" ]]; then
|
|
||||||
. "$REPOTOOL_PATH/.hooks/$1" $2
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
TOOL_BIN="$REPOTOOL_PATH/.bin/repotool"
|
|
||||||
case "$1" in
|
|
||||||
get | g)
|
|
||||||
shift;
|
|
||||||
response=$($TOOL_BIN get $@)
|
|
||||||
if [[ $? != 0 ]] then;
|
|
||||||
echo "failed to get repo with args $@"
|
|
||||||
return $?
|
|
||||||
fi
|
|
||||||
declare -A obj
|
|
||||||
for item in ${(z)response}; do
|
|
||||||
parts=(${(s[=])item})
|
|
||||||
# NOTE: zsh is 1 indexed arrays
|
|
||||||
obj[${parts[1]}]=${parts[2]}
|
|
||||||
done
|
|
||||||
_activate_hook "before_cd" $response
|
|
||||||
cd ${obj[dir]}
|
|
||||||
_activate_hook "after_cd" $response
|
|
||||||
;;
|
|
||||||
'help' | "-h"| "-help" | "--help")
|
|
||||||
echo <<EOF
|
|
||||||
usage:
|
|
||||||
repo get <repo-name>
|
|
||||||
EOF
|
|
||||||
;;
|
|
||||||
"open")
|
|
||||||
raw_url=$(git ls-remote --get-url | cut -d '@' -f2 | sed 's/:/\//1')
|
|
||||||
xdg-open "https://${raw_url}"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
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: ~
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env zsh
|
#!/usr/bin/env zsh
|
||||||
|
|
||||||
[[ -z "$REPOTOOL_PATH" ]] && export REPOTOOL_PATH="$HOME/repo"
|
[[ -z "$REPOTOOL_PATH" ]] && export REPOTOOL_PATH="$HOME/repo"
|
||||||
alias repo=". $REPOTOOL_PATH/.bin/repotool.zsh"
|
alias repo=". $REPOTOOL_PATH/.shell/repotool.zsh"
|
||||||
75
shell/zsh/repotool.zsh
Executable file
75
shell/zsh/repotool.zsh
Executable file
@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env zsh
|
||||||
|
|
||||||
|
[[ -z "$REPOTOOL_PATH" ]] && export REPOTOOL_PATH="$HOME/repo"
|
||||||
|
|
||||||
|
|
||||||
|
_activate_hook() {
|
||||||
|
if [[ -f "$REPOTOOL_PATH/.hooks/$1.zsh" ]]; then
|
||||||
|
. "$REPOTOOL_PATH/.hooks/$1.zsh" $2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ -f "$REPOTOOL_PATH/.hooks/$1.sh" ]]; then
|
||||||
|
. "$REPOTOOL_PATH/.hooks/$1.sh" $2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ -f "$REPOTOOL_PATH/.hooks/$1" ]]; then
|
||||||
|
. "$REPOTOOL_PATH/.hooks/$1" $2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_parse_json() {
|
||||||
|
local response="$1"
|
||||||
|
local key="$2"
|
||||||
|
echo "$response" | jq -r ".$key"
|
||||||
|
}
|
||||||
|
|
||||||
|
_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"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
TOOL_BIN="$REPOTOOL_PATH/.bin/repotool"
|
||||||
|
|
||||||
|
# Pass all arguments to repotool and handle response
|
||||||
|
response=$($TOOL_BIN "$@")
|
||||||
|
exit_code=$?
|
||||||
|
|
||||||
|
if [[ $exit_code != 0 ]]; then
|
||||||
|
echo "Command failed with exit code $exit_code" >&2
|
||||||
|
return $exit_code
|
||||||
|
fi
|
||||||
|
|
||||||
|
_handle_response "$response"
|
||||||
@ -1,46 +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"]
|
|
||||||
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
|
|
||||||
36
src/cmd.lua
Normal file
36
src/cmd.lua
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
local cmd = {}
|
||||||
|
|
||||||
|
-- Execute a command, redirecting stdout to stderr to avoid polluting our JSON output
|
||||||
|
function cmd.execute(command)
|
||||||
|
-- Redirect stdout to stderr so only our JSON goes to stdout
|
||||||
|
local redirected_cmd = command .. " 1>&2"
|
||||||
|
return os.execute(redirected_cmd)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Execute a command and capture its output
|
||||||
|
function cmd.read(command)
|
||||||
|
local handle = io.popen(command .. " 2>&1")
|
||||||
|
local result = handle:read("*a")
|
||||||
|
local success = handle:close()
|
||||||
|
return result, success
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Execute a command and capture stdout separately from stderr
|
||||||
|
function cmd.read_stdout(command)
|
||||||
|
local handle = io.popen(command .. " 2>/dev/null")
|
||||||
|
local result = handle:read("*a")
|
||||||
|
local success = handle:close()
|
||||||
|
return result, success
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Execute a command and return only the exit status
|
||||||
|
function cmd.run(command)
|
||||||
|
return os.execute(command .. " >/dev/null 2>&1")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if a command exists
|
||||||
|
function cmd.exists(command_name)
|
||||||
|
return cmd.run("command -v " .. command_name) == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
return cmd
|
||||||
58
src/fs.lua
Normal file
58
src/fs.lua
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
local cmd = require("cmd")
|
||||||
|
|
||||||
|
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
|
||||||
|
return cmd.run('test -d "' .. path .. '"') == 0
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Create directory with parents
|
||||||
|
function fs.mkdir_p(path)
|
||||||
|
cmd.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 cmd = require("cmd")
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
-- Note: cd command doesn't work in os.execute as it runs in a subshell
|
||||||
|
|
||||||
|
-- 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 .. "'"
|
||||||
|
if cmd.run(check_cmd) == 0 then
|
||||||
|
cmd.execute("git clone '" .. clone_url .. "' '" .. target_dir .. "'")
|
||||||
|
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 @@
|
|||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
local domain
|
|
||||||
local path
|
|
||||||
#now extract the args we need
|
|
||||||
# this is where we use perl
|
|
||||||
if [[ $resp == 1 ]]; then
|
|
||||||
# TODO: properly extract user and password, if exists.
|
|
||||||
output=($(echo ${args[repo]} | ${regex} '/^https?:\/\/(?:.*?:)?(.*\..*?)\/(.*?)(\.git)?$/ && print "$1 $2" ' -))
|
|
||||||
domain=${output[0]}
|
|
||||||
path=${output[1]}
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ $resp == 2 ]]; then
|
|
||||||
# TODO: properly extract ssh user, if exists.
|
|
||||||
output=($(echo ${args[repo]} | ${regex} '/^(?:.*?@)?(.*\..*?)(?::|\/)(.*?)(\.git)?$/ && print "$1 $2" ' -))
|
|
||||||
domain=${output[0]}
|
|
||||||
path=${output[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
|
|
||||||
|
|
||||||
echo dir=$target_dir domain=$domain path=$path repo_url=$repo_url cloned=$cloned
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
141
src/git.lua
Normal file
141
src/git.lua
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
local cmd = require("cmd")
|
||||||
|
|
||||||
|
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 origin_url = cmd.read_stdout("git config --get remote.origin.url")
|
||||||
|
origin_url = origin_url:gsub("\n", "")
|
||||||
|
|
||||||
|
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(command)
|
||||||
|
return cmd.read(command)
|
||||||
|
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 output = cmd.read_stdout("git worktree list --porcelain")
|
||||||
|
local current_worktree = nil
|
||||||
|
|
||||||
|
for line in output:gmatch("[^\n]+") 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
|
||||||
|
|
||||||
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
57
src/open_command.lua
Normal file
57
src/open_command.lua
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
local git = require("git")
|
||||||
|
local json = require("json")
|
||||||
|
local cmd = require("cmd")
|
||||||
|
|
||||||
|
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 raw_url = cmd.read_stdout("git ls-remote --get-url")
|
||||||
|
raw_url = raw_url:gsub("\n", "")
|
||||||
|
|
||||||
|
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
|
||||||
|
if cmd.exists("xdg-open") then
|
||||||
|
-- Linux
|
||||||
|
open_cmd = "xdg-open"
|
||||||
|
elseif cmd.exists("open") then
|
||||||
|
-- macOS
|
||||||
|
open_cmd = "open"
|
||||||
|
elseif cmd.exists("start") then
|
||||||
|
-- Windows
|
||||||
|
open_cmd = "start"
|
||||||
|
else
|
||||||
|
io.stderr:write("Error: Unable to detect platform open command\n")
|
||||||
|
os.exit(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Open the URL
|
||||||
|
cmd.execute(open_cmd .. " '" .. https_url .. "'")
|
||||||
|
|
||||||
|
-- Output JSON
|
||||||
|
print(json.encode({
|
||||||
|
echo = "Opening " .. https_url .. " in browser...",
|
||||||
|
hook = "open"
|
||||||
|
}))
|
||||||
|
end
|
||||||
|
|
||||||
|
return open_command
|
||||||
207
src/worktree.lua
Normal file
207
src/worktree.lua
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
local git = require("git")
|
||||||
|
local json = require("json")
|
||||||
|
local fs = require("fs")
|
||||||
|
local cmd = require("cmd")
|
||||||
|
|
||||||
|
local worktree = {}
|
||||||
|
|
||||||
|
-- Handle the root/return subcommand
|
||||||
|
function worktree.handle_root()
|
||||||
|
-- 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()
|
||||||
|
|
||||||
|
-- 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"}))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Handle the list subcommand
|
||||||
|
function worktree.handle_list()
|
||||||
|
-- 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
|
||||||
|
|
||||||
|
-- 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
|
||||||
|
|
||||||
|
-- Get repository root
|
||||||
|
local repo_root = git.get_repo_root()
|
||||||
|
|
||||||
|
-- Calculate worktree base directory
|
||||||
|
local repotool_path = os.getenv("REPOTOOL_PATH") or os.getenv("HOME") .. "/repo"
|
||||||
|
local worktree_base = repotool_path .. "/.worktree/" .. domain .. "/" .. path
|
||||||
|
|
||||||
|
-- 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
|
||||||
|
-- Skip the main repository
|
||||||
|
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"}))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Handle the get subcommand
|
||||||
|
function worktree.handle_get(worktree_name)
|
||||||
|
-- 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
|
||||||
|
|
||||||
|
-- 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
|
||||||
|
|
||||||
|
-- Construct worktree path
|
||||||
|
local worktree_path = worktree_base .. "/" .. worktree_name
|
||||||
|
|
||||||
|
-- Check if a prunable worktree exists at this path
|
||||||
|
local prunable_output = cmd.read_stdout("git worktree list | grep '" .. worktree_path .. ".*prunable'")
|
||||||
|
if prunable_output ~= "" then
|
||||||
|
io.stderr:write("Found prunable worktree at " .. worktree_path .. ", cleaning up...\n")
|
||||||
|
cmd.execute("git worktree prune")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if worktree already exists
|
||||||
|
local created = "false"
|
||||||
|
local exists_output = cmd.read_stdout("git worktree list | grep '" .. worktree_path .. "'")
|
||||||
|
if exists_output == "" then
|
||||||
|
-- Create parent directories if they don't exist
|
||||||
|
fs.mkdir_p(fs.dirname(worktree_path))
|
||||||
|
|
||||||
|
-- Create the worktree (try different methods)
|
||||||
|
local success = cmd.run("git worktree add '" .. worktree_path .. "' -b '" .. worktree_name .. "'") == 0
|
||||||
|
if not success then
|
||||||
|
success = cmd.run("git worktree add '" .. worktree_path .. "' '" .. worktree_name .. "'") == 0
|
||||||
|
end
|
||||||
|
if not success then
|
||||||
|
cmd.execute("git worktree add '" .. worktree_path .. "'")
|
||||||
|
end
|
||||||
|
created = "true"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Output JSON
|
||||||
|
print(json.encode({
|
||||||
|
cd = worktree_path,
|
||||||
|
domain = domain,
|
||||||
|
path = path,
|
||||||
|
worktree_name = worktree_name,
|
||||||
|
created = created,
|
||||||
|
hook = "worktree"
|
||||||
|
}))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Handle the remove/delete subcommand
|
||||||
|
function worktree.handle_remove(worktree_name)
|
||||||
|
-- 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
|
||||||
|
|
||||||
|
-- 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
|
||||||
|
|
||||||
|
-- Construct worktree path
|
||||||
|
local worktree_path = worktree_base .. "/" .. worktree_name
|
||||||
|
|
||||||
|
-- Check if worktree exists
|
||||||
|
local exists_output = cmd.read_stdout("git worktree list | grep '" .. worktree_path .. "'")
|
||||||
|
if exists_output == "" then
|
||||||
|
io.stderr:write("Error: Worktree '" .. worktree_name .. "' not found\n")
|
||||||
|
os.exit(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Remove the worktree
|
||||||
|
io.stderr:write("Removing worktree '" .. worktree_name .. "'...\n")
|
||||||
|
local success = cmd.run("git worktree remove '" .. worktree_path .. "'") == 0
|
||||||
|
|
||||||
|
if not success then
|
||||||
|
-- Try with --force if normal remove fails
|
||||||
|
io.stderr:write("Normal remove failed, trying with --force...\n")
|
||||||
|
success = cmd.run("git worktree remove --force '" .. worktree_path .. "'") == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
if success then
|
||||||
|
io.stderr:write("Successfully removed worktree '" .. worktree_name .. "'\n")
|
||||||
|
print(json.encode({
|
||||||
|
removed = worktree_name,
|
||||||
|
path = worktree_path,
|
||||||
|
hook = "worktree.remove"
|
||||||
|
}))
|
||||||
|
else
|
||||||
|
io.stderr:write("Error: Failed to remove worktree '" .. worktree_name .. "'\n")
|
||||||
|
os.exit(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return worktree
|
||||||
Loading…
Reference in New Issue
Block a user