Compare commits

...

2 Commits

Author SHA1 Message Date
a
9470d7c36b
noot 2025-06-30 19:56:36 -05:00
a
46d7fbab1e
noot 2025-06-30 18:46:08 -05:00
21 changed files with 2113 additions and 5609 deletions

View File

@ -1,22 +1,18 @@
.PHONY: all install
SOURCES_LIBS:=$(shell find 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/
src/lib/repotool/stdlib.sh: $(SOURCES_LIBS)
bashly add --source . stdlib -f
dist/repotool: $(SOURCES_SRC)
mkdir -p dist
bashly generate
mv repotool dist
install dist/repotool shell/zsh/repotool.zsh shell/zsh/repotool.plugin.zsh ${REPOTOOL_PATH}/.bin/
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
View 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
View 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
View 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

View File

@ -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
}

View File

@ -1,5 +0,0 @@
stdlib:
help: stdlib for repotool
files:
- source: "lib/stdlib.sh"
target: "%{user_lib_dir}/repotool/stdlib.%{user_ext}"

114
main.lua Executable file
View 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())

View File

@ -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"
}

View File

@ -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

View File

@ -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: ~

75
shell/zsh/repotool.zsh Executable file
View 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"

View File

@ -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

59
src/fs.lua Normal file
View 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
View 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

View File

@ -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

145
src/git.lua Normal file
View 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

View File

@ -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
View 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

139
src/worktree_command.lua Normal file
View 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

5205
yarn.lock

File diff suppressed because it is too large Load Diff