This commit is contained in:
a 2025-06-30 20:23:13 -05:00
parent 9470d7c36b
commit 97f8909001
No known key found for this signature in database
GPG Key ID: 2F22877AA4DFDADB
10 changed files with 327 additions and 214 deletions

View File

@ -9,7 +9,9 @@ all: dist/repotool
install: dist/repotool shell/zsh/repotool.zsh shell/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 shell/zsh/repotool.zsh shell/zsh/repotool.plugin.zsh ${REPOTOOL_PATH}/.bin/ mkdir -p ${REPOTOOL_PATH}/.shell/
install dist/repotool ${REPOTOOL_PATH}/.bin/
install shell/zsh/repotool.zsh shell/zsh/repotool.plugin.zsh ${REPOTOOL_PATH}/.shell/
dist/repotool: $(SOURCES_SRC) $(LIBS_SRC) main.lua dist/repotool: $(SOURCES_SRC) $(LIBS_SRC) main.lua
@mkdir -p dist @mkdir -p dist

View File

@ -11,7 +11,7 @@ local json = require("json")
-- Load command modules -- Load command modules
local get_command = require("get_command") local get_command = require("get_command")
local worktree_command = require("worktree_command") local worktree = require("worktree")
local open_command = require("open_command") local open_command = require("open_command")
-- Create the main command with subcommands -- Create the main command with subcommands
@ -62,41 +62,60 @@ local app = cli.cmd {
return 0 return 0
end), end),
}, },
-- Worktree command -- Worktree command with subcommands
cli.cmd { cli.cmd {
'worktree', 'w', 'wt', 'worktree', 'w', 'wt',
desc = 'goes to or creates a worktree with the name for the repo you are in', desc = 'manage git worktrees',
term = cli.all { subs = {
name = cli.arg { -- List subcommand
'name', cli.cmd {
desc = 'Name of the worktree', 'list', 'ls',
required = false, desc = 'list existing worktrees for this repo',
term = cli.val():and_then(function()
worktree.handle_list()
return 0
end),
}, },
list = cli.opt { -- Get subcommand
'--list', '-l', cli.cmd {
desc = 'List existing worktrees for this repo', 'get',"new","create","n","c",
flag = true, 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 = cli.opt { -- Root subcommand
'--root', '-r', cli.cmd {
desc = 'Return the root directory of the original repo', 'root', 'back', 'r', 'return',
flag = true, desc = 'return to the root directory of the original repo',
term = cli.val():and_then(function()
worktree.handle_root()
return 0
end),
}, },
}:and_then(function(args) -- Remove subcommand
-- Handle special cases: "list" and "ls" as commands cli.cmd {
local name = args.name 'remove', 'rm', 'delete', 'del',
if name == "list" or name == "ls" then desc = 'remove a worktree',
args.list = true term = cli.all {
args.name = nil name = cli.arg {
end 'name',
desc = 'Name of the worktree to remove',
worktree_command({ required = true,
name = args.name, },
list = args.list, }:and_then(function(args)
root = args.root, worktree.handle_remove(args.name)
}) return 0
return 0 end),
end), },
},
}, },
-- Open command -- Open command
cli.cmd { cli.cmd {

View File

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

36
src/cmd.lua Normal file
View 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

View File

@ -1,3 +1,5 @@
local cmd = require("cmd")
local fs = {} local fs = {}
-- Check if a file exists -- Check if a file exists
@ -13,17 +15,14 @@ function fs.dir_exists(path)
if f then if f then
io.close(f) io.close(f)
-- Check if it's actually a directory by trying to list it -- Check if it's actually a directory by trying to list it
local handle = io.popen('test -d "' .. path .. '" && echo "yes" || echo "no"') return cmd.run('test -d "' .. path .. '"') == 0
local result = handle:read("*a"):gsub("\n", "")
handle:close()
return result == "yes"
end end
return false return false
end end
-- Create directory with parents -- Create directory with parents
function fs.mkdir_p(path) function fs.mkdir_p(path)
os.execute('mkdir -p "' .. path .. '"') cmd.execute('mkdir -p "' .. path .. '"')
end end
-- Get the parent directory of a path -- Get the parent directory of a path

View File

@ -1,6 +1,7 @@
local git = require("git") local git = require("git")
local json = require("json") local json = require("json")
local fs = require("fs") local fs = require("fs")
local cmd = require("cmd")
local function get_command(args) local function get_command(args)
-- Validate URL -- Validate URL
@ -53,8 +54,7 @@ http_pass: %s
fs.mkdir_p(target_dir) fs.mkdir_p(target_dir)
end end
-- Change to target directory -- Note: cd command doesn't work in os.execute as it runs in a subshell
os.execute("cd '" .. target_dir .. "'")
-- Construct repo URL based on method -- Construct repo URL based on method
local clone_url local clone_url
@ -73,9 +73,9 @@ http_pass: %s
local git_dir = fs.join(target_dir, ".git") local git_dir = fs.join(target_dir, ".git")
if not fs.dir_exists(git_dir) then if not fs.dir_exists(git_dir) then
-- Check if remote exists -- Check if remote exists
local check_cmd = "cd '" .. target_dir .. "' && git ls-remote '" .. clone_url .. "' >/dev/null 2>&1" local check_cmd = "cd '" .. target_dir .. "' && git ls-remote '" .. clone_url .. "'"
if os.execute(check_cmd) == 0 then if cmd.run(check_cmd) == 0 then
os.execute("git clone '" .. clone_url .. "' '" .. target_dir .. "' >&2") cmd.execute("git clone '" .. clone_url .. "' '" .. target_dir .. "'")
cloned = "true" cloned = "true"
else else
io.stderr:write("Could not find repo: " .. clone_url .. "\n") io.stderr:write("Could not find repo: " .. clone_url .. "\n")

View File

@ -1,3 +1,5 @@
local cmd = require("cmd")
local git = {} local git = {}
-- Parse a git URL into domain and path components -- Parse a git URL into domain and path components
@ -34,9 +36,8 @@ end
-- Get domain and path from current git repository's origin -- Get domain and path from current git repository's origin
function git.parse_origin() function git.parse_origin()
local handle = io.popen("git config --get remote.origin.url 2>/dev/null") local origin_url = cmd.read_stdout("git config --get remote.origin.url")
local origin_url = handle:read("*a"):gsub("\n", "") origin_url = origin_url:gsub("\n", "")
handle:close()
if origin_url == "" then if origin_url == "" then
return nil, nil return nil, nil
@ -59,11 +60,8 @@ function git.valid_url(url)
end end
-- Execute command and return output -- Execute command and return output
function git.execute(cmd) function git.execute(command)
local handle = io.popen(cmd .. " 2>&1") return cmd.read(command)
local result = handle:read("*a")
local success = handle:close()
return result, success
end end
-- Check if we're in a git repository -- Check if we're in a git repository
@ -93,10 +91,10 @@ end
-- Get list of all worktrees with their properties -- Get list of all worktrees with their properties
function git.worktree_list() function git.worktree_list()
local worktrees = {} local worktrees = {}
local handle = io.popen("git worktree list --porcelain") local output = cmd.read_stdout("git worktree list --porcelain")
local current_worktree = nil local current_worktree = nil
for line in handle:lines() do for line in output:gmatch("[^\n]+") do
local worktree_path = line:match("^worktree (.+)$") local worktree_path = line:match("^worktree (.+)$")
if worktree_path then if worktree_path then
-- Save previous worktree if any -- Save previous worktree if any
@ -137,8 +135,6 @@ function git.worktree_list()
table.insert(worktrees, current_worktree) table.insert(worktrees, current_worktree)
end end
handle:close()
return worktrees return worktrees
end end

View File

@ -1,5 +1,6 @@
local git = require("git") local git = require("git")
local json = require("json") local json = require("json")
local cmd = require("cmd")
local function open_command(args) local function open_command(args)
-- Check if we're in a git repository -- Check if we're in a git repository
@ -9,9 +10,8 @@ local function open_command(args)
end end
-- Get the remote URL -- Get the remote URL
local handle = io.popen("git ls-remote --get-url") local raw_url = cmd.read_stdout("git ls-remote --get-url")
local raw_url = handle:read("*a"):gsub("\n", "") raw_url = raw_url:gsub("\n", "")
handle:close()
if raw_url == "" then if raw_url == "" then
io.stderr:write("Error: No remote URL found\n") io.stderr:write("Error: No remote URL found\n")
@ -30,29 +30,22 @@ local function open_command(args)
-- Detect platform and open URL -- Detect platform and open URL
local open_cmd local open_cmd
local result = os.execute("command -v xdg-open >/dev/null 2>&1") if cmd.exists("xdg-open") then
if result == true or result == 0 then
-- Linux -- Linux
open_cmd = "xdg-open" open_cmd = "xdg-open"
elseif cmd.exists("open") then
-- macOS
open_cmd = "open"
elseif cmd.exists("start") then
-- Windows
open_cmd = "start"
else else
result = os.execute("command -v open >/dev/null 2>&1") io.stderr:write("Error: Unable to detect platform open command\n")
if result == true or result == 0 then os.exit(1)
-- 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 end
-- Open the URL -- Open the URL
os.execute(open_cmd .. " '" .. https_url .. "' 2>/dev/null") cmd.execute(open_cmd .. " '" .. https_url .. "'")
-- Output JSON -- Output JSON
print(json.encode({ print(json.encode({

207
src/worktree.lua Normal file
View 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

View File

@ -1,139 +0,0 @@
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