main.lua

   1#!/usr/bin/env lua
   2
   3-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
   4--
   5-- SPDX-License-Identifier: GPL-3.0-or-later
   6
   7if _VERSION < "Lua 5.2" then
   8	io.stderr:write("error: wt requires Lua 5.2 or later\n")
   9	os.exit(1)
  10end
  11
  12local exit = require("wt.exit")
  13local EXIT_SUCCESS = exit.EXIT_SUCCESS
  14local EXIT_USER_ERROR = exit.EXIT_USER_ERROR
  15local EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR
  16
  17local shell = require("wt.shell")
  18local run_cmd = shell.run_cmd
  19local run_cmd_silent = shell.run_cmd_silent
  20local get_cwd = shell.get_cwd
  21local die = shell.die
  22
  23local path_mod = require("wt.path")
  24local branch_to_path = path_mod.branch_to_path
  25local split_path = path_mod.split_path
  26local relative_path = path_mod.relative_path
  27local path_inside = path_mod.path_inside
  28local escape_pattern = path_mod.escape_pattern
  29
  30local git_mod = require("wt.git")
  31local find_project_root = git_mod.find_project_root
  32local detect_source_worktree = git_mod.detect_source_worktree
  33local branch_exists_local = git_mod.branch_exists_local
  34local find_branch_remotes = git_mod.find_branch_remotes
  35local detect_cloned_default_branch = git_mod.detect_cloned_default_branch
  36local get_default_branch = git_mod.get_default_branch
  37local parse_branch_remotes = git_mod.parse_branch_remotes
  38local parse_worktree_list = git_mod.parse_worktree_list
  39local branch_checked_out_at = git_mod.branch_checked_out_at
  40
  41local config_mod = require("wt.config")
  42local resolve_url_template = config_mod.resolve_url_template
  43local extract_project_name = config_mod.extract_project_name
  44local load_global_config = config_mod.load_global_config
  45local load_project_config = config_mod.load_project_config
  46
  47local hooks_mod = require("wt.hooks")
  48local load_hook_permissions = hooks_mod.load_hook_permissions
  49local save_hook_permissions = hooks_mod.save_hook_permissions
  50local summarize_hooks = hooks_mod.summarize_hooks
  51local run_hooks = hooks_mod.run_hooks
  52
  53---Print usage information
  54local function print_usage()
  55	print("wt - git worktree manager")
  56	print("")
  57	print("Usage: wt <command> [options]")
  58	print("")
  59	print("Commands:")
  60	print("  c <url> [--remote name]... [--own]   Clone into bare worktree structure")
  61	print("  n <project-name> [--remote name]...  Initialize fresh project")
  62	print("  a <branch> [-b [<start-point>]]      Add worktree with optional hooks")
  63	print("  r <branch> [-b] [-f]                 Remove worktree, optionally delete branch")
  64	print("  l                                    List worktrees with status")
  65	print("  f                                    Fetch all remotes")
  66	print("  init [--dry-run] [-y]                Convert existing repo to bare structure")
  67	print("  help                                 Show this help message")
  68end
  69
  70-- Per-command help text (using table.concat for performance)
  71local COMMAND_HELP = {
  72	c = table.concat({
  73		"wt c <url> [--remote name]... [--own]",
  74		"",
  75		"Clone a repository into bare worktree structure.",
  76		"",
  77		"Arguments:",
  78		"  <url>              Git URL to clone",
  79		"",
  80		"Options:",
  81		"  --remote <name>    Add configured remote from ~/.config/wt/config.lua",
  82		"                     Can be specified multiple times",
  83		"  --own              Treat as your own project: first remote becomes 'origin'",
  84		"                     (default: 'origin' renamed to 'upstream', your remotes added)",
  85		"",
  86		"Examples:",
  87		"  wt c https://github.com/user/repo.git",
  88		"  wt c git@github.com:user/repo.git --remote github --own",
  89	}, "\n"),
  90
  91	n = table.concat({
  92		"wt n <project-name> [--remote name]...",
  93		"",
  94		"Initialize a fresh project with bare worktree structure.",
  95		"",
  96		"Arguments:",
  97		"  <project-name>     Name of the new project directory",
  98		"",
  99		"Options:",
 100		"  --remote <name>    Add configured remote from ~/.config/wt/config.lua",
 101		"                     Can be specified multiple times",
 102		"",
 103		"Examples:",
 104		"  wt n my-project",
 105		"  wt n my-project --remote github --remote gitlab",
 106	}, "\n"),
 107
 108	a = table.concat({
 109		"wt a <branch> [-b [<start-point>]]",
 110		"",
 111		"Add a worktree for a branch.",
 112		"",
 113		"Arguments:",
 114		"  <branch>           Branch name to checkout or create",
 115		"",
 116		"Options:",
 117		"  -b                 Create a new branch",
 118		"  <start-point>      Base commit/branch for new branch (only with -b)",
 119		"",
 120		"If run from inside an existing worktree, hooks from .wt.lua will be applied.",
 121		"",
 122		"Examples:",
 123		"  wt a main                    # Checkout existing branch",
 124		"  wt a feature/new -b          # Create new branch from HEAD",
 125		"  wt a feature/new -b main     # Create new branch from main",
 126	}, "\n"),
 127
 128	r = table.concat({
 129		"wt r <branch> [-b] [-f]",
 130		"",
 131		"Remove a worktree.",
 132		"",
 133		"Arguments:",
 134		"  <branch>           Branch name of worktree to remove",
 135		"",
 136		"Options:",
 137		"  -b                 Also delete the branch after removing worktree",
 138		"  -f                 Force removal even with uncommitted changes",
 139		"",
 140		"Examples:",
 141		"  wt r feature/old             # Remove worktree, keep branch",
 142		"  wt r feature/old -b          # Remove worktree and delete branch",
 143		"  wt r feature/old -f          # Force remove with uncommitted changes",
 144	}, "\n"),
 145
 146	l = table.concat({
 147		"wt l",
 148		"",
 149		"List all worktrees with status information.",
 150		"",
 151		"Displays a table showing:",
 152		"  - Branch name",
 153		"  - Relative path from project root",
 154		"  - Commit status (ahead/behind remote)",
 155		"  - Working tree status (clean/dirty)",
 156	}, "\n"),
 157
 158	f = table.concat({
 159		"wt f",
 160		"",
 161		"Fetch from all configured remotes.",
 162		"",
 163		"Runs 'git fetch --all' in the bare repository.",
 164	}, "\n"),
 165
 166	init = table.concat({
 167		"wt init [--dry-run] [-y]",
 168		"",
 169		"Convert an existing git repository to bare worktree structure.",
 170		"",
 171		"Options:",
 172		"  --dry-run          Show what would be done without making changes",
 173		"  -y                 Skip confirmation prompt",
 174		"",
 175		"This command:",
 176		"  1. Moves .git/ to .bare/",
 177		"  2. Creates .git file pointing to .bare/",
 178		"  3. Creates a worktree for the current branch",
 179		"  4. Removes orphaned files from project root",
 180	}, "\n"),
 181}
 182
 183---Show help for a specific command
 184---@param cmd string
 185local function show_command_help(cmd)
 186	local help = COMMAND_HELP[cmd]
 187	if help then
 188		print(help)
 189	else
 190		print_usage()
 191	end
 192	os.exit(EXIT_SUCCESS)
 193end
 194
 195---@param args string[]
 196local function cmd_clone(args)
 197	-- Parse arguments: <url> [--remote name]... [--own]
 198	local url = nil
 199	---@type string[]
 200	local remote_flags = {}
 201	local own = false
 202
 203	local i = 1
 204	while i <= #args do
 205		local a = args[i]
 206		if a == "--remote" then
 207			if not args[i + 1] then
 208				die("--remote requires a name")
 209			end
 210			table.insert(remote_flags, args[i + 1])
 211			i = i + 1
 212		elseif a == "--own" then
 213			own = true
 214		elseif not url then
 215			url = a
 216		else
 217			die("unexpected argument: " .. a)
 218		end
 219		i = i + 1
 220	end
 221
 222	if not url then
 223		die("usage: wt c <url> [--remote name]... [--own]")
 224		return
 225	end
 226
 227	-- Extract project name from URL
 228	local project_name = extract_project_name(url)
 229	if not project_name then
 230		die("could not extract project name from URL: " .. url)
 231		return
 232	end
 233
 234	-- Check if project directory already exists
 235	local cwd = get_cwd()
 236	if not cwd then
 237		die("failed to get current directory", EXIT_SYSTEM_ERROR)
 238	end
 239	local project_path = cwd .. "/" .. project_name
 240	local check = io.open(project_path, "r")
 241	if check then
 242		check:close()
 243		die("directory already exists: " .. project_path)
 244	end
 245
 246	-- Clone bare repo
 247	local bare_path = project_path .. "/.bare"
 248	local output, code = run_cmd("git clone --bare " .. url .. " " .. bare_path)
 249	if code ~= 0 then
 250		die("failed to clone: " .. output, EXIT_SYSTEM_ERROR)
 251	end
 252
 253	-- Write .git file pointing to .bare
 254	local git_file_handle = io.open(project_path .. "/.git", "w")
 255	if not git_file_handle then
 256		die("failed to create .git file", EXIT_SYSTEM_ERROR)
 257		return
 258	end
 259	git_file_handle:write("gitdir: ./.bare\n")
 260	git_file_handle:close()
 261
 262	-- Detect default branch
 263	local git_dir = bare_path
 264	local default_branch = detect_cloned_default_branch(git_dir)
 265
 266	-- Load global config
 267	local global_config = load_global_config()
 268
 269	-- Determine which remotes to use
 270	---@type string[]
 271	local selected_remotes = {}
 272
 273	if #remote_flags > 0 then
 274		selected_remotes = remote_flags
 275	elseif global_config.default_remotes then
 276		if type(global_config.default_remotes) == "table" then
 277			selected_remotes = global_config.default_remotes
 278		elseif global_config.default_remotes == "prompt" then
 279			if global_config.remotes then
 280				local keys = {}
 281				for k in pairs(global_config.remotes) do
 282					table.insert(keys, k)
 283				end
 284				table.sort(keys)
 285				if #keys > 0 then
 286					local input = table.concat(keys, "\n")
 287					local choose_type = own and "" or " --no-limit"
 288					local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
 289					output, code = run_cmd(cmd)
 290					if code == 0 and output ~= "" then
 291						for line in output:gmatch("[^\n]+") do
 292							table.insert(selected_remotes, line)
 293						end
 294					end
 295				end
 296			end
 297		end
 298	elseif global_config.remotes then
 299		local keys = {}
 300		for k in pairs(global_config.remotes) do
 301			table.insert(keys, k)
 302		end
 303		table.sort(keys)
 304		if #keys > 0 then
 305			local input = table.concat(keys, "\n")
 306			local choose_type = own and "" or " --no-limit"
 307			local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
 308			output, code = run_cmd(cmd)
 309			if code == 0 and output ~= "" then
 310				for line in output:gmatch("[^\n]+") do
 311					table.insert(selected_remotes, line)
 312				end
 313			end
 314		end
 315	end
 316
 317	-- Track configured remotes for summary
 318	---@type string[]
 319	local configured_remotes = {}
 320
 321	if own then
 322		-- User's own project: origin is their canonical remote
 323		if #selected_remotes > 0 then
 324			local first_remote = selected_remotes[1]
 325			-- Rename origin to first remote
 326			output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin " .. first_remote)
 327			if code ~= 0 then
 328				io.stderr:write("warning: failed to rename origin to " .. first_remote .. ": " .. output .. "\n")
 329			else
 330				-- Configure fetch refspec
 331				run_cmd(
 332					"GIT_DIR="
 333						.. git_dir
 334						.. " git config remote."
 335						.. first_remote
 336						.. ".fetch '+refs/heads/*:refs/remotes/"
 337						.. first_remote
 338						.. "/*'"
 339				)
 340				table.insert(configured_remotes, first_remote)
 341			end
 342
 343			-- Add additional remotes and push to them
 344			for j = 2, #selected_remotes do
 345				local remote_name = selected_remotes[j]
 346				local template = global_config.remotes and global_config.remotes[remote_name]
 347				if template then
 348					local remote_url = resolve_url_template(template, project_name)
 349					output, code =
 350						run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
 351					if code ~= 0 then
 352						io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
 353					else
 354						run_cmd(
 355							"GIT_DIR="
 356								.. git_dir
 357								.. " git config remote."
 358								.. remote_name
 359								.. ".fetch '+refs/heads/*:refs/remotes/"
 360								.. remote_name
 361								.. "/*'"
 362						)
 363						-- Push to additional remotes
 364						output, code =
 365							run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
 366						if code ~= 0 then
 367							io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
 368						end
 369						table.insert(configured_remotes, remote_name)
 370					end
 371				else
 372					io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
 373				end
 374			end
 375		else
 376			-- No remotes selected, keep origin as-is
 377			run_cmd("GIT_DIR=" .. git_dir .. " git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'")
 378			table.insert(configured_remotes, "origin")
 379		end
 380	else
 381		-- Contributing to someone else's project
 382		-- Rename origin to upstream
 383		output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin upstream")
 384		if code ~= 0 then
 385			io.stderr:write("warning: failed to rename origin to upstream: " .. output .. "\n")
 386		else
 387			run_cmd(
 388				"GIT_DIR=" .. git_dir .. " git config remote.upstream.fetch '+refs/heads/*:refs/remotes/upstream/*'"
 389			)
 390			table.insert(configured_remotes, "upstream")
 391		end
 392
 393		-- Add user's remotes and push to each
 394		for _, remote_name in ipairs(selected_remotes) do
 395			local template = global_config.remotes and global_config.remotes[remote_name]
 396			if template then
 397				local remote_url = resolve_url_template(template, project_name)
 398				output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
 399				if code ~= 0 then
 400					io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
 401				else
 402					run_cmd(
 403						"GIT_DIR="
 404							.. git_dir
 405							.. " git config remote."
 406							.. remote_name
 407							.. ".fetch '+refs/heads/*:refs/remotes/"
 408							.. remote_name
 409							.. "/*'"
 410					)
 411					-- Push to this remote
 412					output, code =
 413						run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
 414					if code ~= 0 then
 415						io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
 416					end
 417					table.insert(configured_remotes, remote_name)
 418				end
 419			else
 420				io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
 421			end
 422		end
 423	end
 424
 425	-- Fetch all remotes
 426	run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all")
 427
 428	-- Load config for path style
 429	local style = global_config.branch_path_style or "nested"
 430	local separator = global_config.flat_separator
 431	local worktree_path = branch_to_path(project_path, default_branch, style, separator)
 432
 433	-- Create initial worktree
 434	output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
 435	if code ~= 0 then
 436		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
 437	end
 438
 439	-- Print summary
 440	print("Created project: " .. project_path)
 441	print("Default branch:  " .. default_branch)
 442	print("Worktree:        " .. worktree_path)
 443	if #configured_remotes > 0 then
 444		print("Remotes:         " .. table.concat(configured_remotes, ", "))
 445	end
 446end
 447
 448---@param args string[]
 449local function cmd_new(args)
 450	-- Parse arguments: <project-name> [--remote name]...
 451	local project_name = nil
 452	---@type string[]
 453	local remote_flags = {}
 454
 455	local i = 1
 456	while i <= #args do
 457		local a = args[i]
 458		if a == "--remote" then
 459			if not args[i + 1] then
 460				die("--remote requires a name")
 461			end
 462			table.insert(remote_flags, args[i + 1])
 463			i = i + 1
 464		elseif not project_name then
 465			project_name = a
 466		else
 467			die("unexpected argument: " .. a)
 468		end
 469		i = i + 1
 470	end
 471
 472	if not project_name then
 473		die("usage: wt n <project-name> [--remote name]...")
 474		return
 475	end
 476
 477	-- Check if project directory already exists
 478	local cwd = get_cwd()
 479	if not cwd then
 480		die("failed to get current directory", EXIT_SYSTEM_ERROR)
 481	end
 482	local project_path = cwd .. "/" .. project_name
 483	local check = io.open(project_path, "r")
 484	if check then
 485		check:close()
 486		die("directory already exists: " .. project_path)
 487	end
 488
 489	-- Load global config
 490	local global_config = load_global_config()
 491
 492	-- Determine which remotes to use
 493	---@type string[]
 494	local selected_remotes = {}
 495
 496	if #remote_flags > 0 then
 497		-- Use explicitly provided remotes
 498		selected_remotes = remote_flags
 499	elseif global_config.default_remotes then
 500		if type(global_config.default_remotes) == "table" then
 501			selected_remotes = global_config.default_remotes
 502		elseif global_config.default_remotes == "prompt" then
 503			-- Prompt with gum choose
 504			if global_config.remotes then
 505				local keys = {}
 506				for k in pairs(global_config.remotes) do
 507					table.insert(keys, k)
 508				end
 509				table.sort(keys)
 510				if #keys > 0 then
 511					local input = table.concat(keys, "\n")
 512					local cmd = "echo '" .. input .. "' | gum choose --no-limit"
 513					local output, code = run_cmd(cmd)
 514					if code == 0 and output ~= "" then
 515						for line in output:gmatch("[^\n]+") do
 516							table.insert(selected_remotes, line)
 517						end
 518					end
 519				end
 520			end
 521		end
 522	elseif global_config.remotes then
 523		-- No default_remotes configured, prompt if remotes exist
 524		local keys = {}
 525		for k in pairs(global_config.remotes) do
 526			table.insert(keys, k)
 527		end
 528		table.sort(keys)
 529		if #keys > 0 then
 530			local input = table.concat(keys, "\n")
 531			local cmd = "echo '" .. input .. "' | gum choose --no-limit"
 532			local output, code = run_cmd(cmd)
 533			if code == 0 and output ~= "" then
 534				for line in output:gmatch("[^\n]+") do
 535					table.insert(selected_remotes, line)
 536				end
 537			end
 538		end
 539	end
 540
 541	-- Create project structure
 542	local bare_path = project_path .. "/.bare"
 543	local output, code = run_cmd("mkdir -p " .. bare_path)
 544	if code ~= 0 then
 545		die("failed to create directory: " .. output, EXIT_SYSTEM_ERROR)
 546	end
 547
 548	output, code = run_cmd("git init --bare " .. bare_path)
 549	if code ~= 0 then
 550		die("failed to init bare repo: " .. output, EXIT_SYSTEM_ERROR)
 551	end
 552
 553	-- Write .git file pointing to .bare
 554	local git_file_handle = io.open(project_path .. "/.git", "w")
 555	if not git_file_handle then
 556		die("failed to create .git file", EXIT_SYSTEM_ERROR)
 557		return
 558	end
 559	git_file_handle:write("gitdir: ./.bare\n")
 560	git_file_handle:close()
 561
 562	-- Add remotes
 563	local git_dir = bare_path
 564	for _, remote_name in ipairs(selected_remotes) do
 565		local template = global_config.remotes and global_config.remotes[remote_name]
 566		if template then
 567			local url = resolve_url_template(template, project_name)
 568			output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. url)
 569			if code ~= 0 then
 570				io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
 571			else
 572				-- Configure fetch refspec for the remote
 573				run_cmd(
 574					"GIT_DIR="
 575						.. git_dir
 576						.. " git config remote."
 577						.. remote_name
 578						.. ".fetch '+refs/heads/*:refs/remotes/"
 579						.. remote_name
 580						.. "/*'"
 581				)
 582			end
 583		else
 584			io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
 585		end
 586	end
 587
 588	-- Detect default branch
 589	local default_branch = get_default_branch()
 590
 591	-- Load config for path style
 592	local style = global_config.branch_path_style or "nested"
 593	local separator = global_config.flat_separator
 594	local worktree_path = branch_to_path(project_path, default_branch, style, separator)
 595
 596	-- Create orphan worktree
 597	output, code =
 598		run_cmd("GIT_DIR=" .. git_dir .. " git worktree add --orphan -b " .. default_branch .. " -- " .. worktree_path)
 599	if code ~= 0 then
 600		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
 601	end
 602
 603	-- Print summary
 604	print("Created project: " .. project_path)
 605	print("Default branch:  " .. default_branch)
 606	print("Worktree:        " .. worktree_path)
 607	if #selected_remotes > 0 then
 608		print("Remotes:         " .. table.concat(selected_remotes, ", "))
 609	end
 610end
 611
 612---@param args string[]
 613local function cmd_add(args)
 614	-- Parse arguments: <branch> [-b [<start-point>]]
 615	---@type string|nil
 616	local branch = nil
 617	local create_branch = false
 618	---@type string|nil
 619	local start_point = nil
 620
 621	local i = 1
 622	while i <= #args do
 623		local a = args[i]
 624		if a == "-b" then
 625			create_branch = true
 626			-- Check if next arg is start-point (not another flag)
 627			if args[i + 1] and not args[i + 1]:match("^%-") then
 628				start_point = args[i + 1]
 629				i = i + 1
 630			end
 631		elseif not branch then
 632			branch = a
 633		else
 634			die("unexpected argument: " .. a)
 635		end
 636		i = i + 1
 637	end
 638
 639	if not branch then
 640		die("usage: wt a <branch> [-b [<start-point>]]")
 641		return
 642	end
 643
 644	local root, err = find_project_root()
 645	if not root then
 646		die(err --[[@as string]])
 647		return
 648	end
 649
 650	local git_dir = root .. "/.bare"
 651	local source_worktree = detect_source_worktree(root)
 652
 653	-- Load config for path style
 654	local global_config = load_global_config()
 655	local style = global_config.branch_path_style or "nested"
 656	local separator = global_config.flat_separator or "_"
 657
 658	local target_path = branch_to_path(root, branch, style, separator)
 659
 660	-- Check if target already exists
 661	local check = io.open(target_path .. "/.git", "r")
 662	if check then
 663		check:close()
 664		die("worktree already exists at " .. target_path)
 665	end
 666
 667	local output, code
 668	if create_branch then
 669		-- Create new branch with worktree
 670		if start_point then
 671			output, code = run_cmd(
 672				"GIT_DIR="
 673					.. git_dir
 674					.. " git worktree add -b "
 675					.. branch
 676					.. " -- "
 677					.. target_path
 678					.. " "
 679					.. start_point
 680			)
 681		else
 682			output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -b " .. branch .. " -- " .. target_path)
 683		end
 684	else
 685		-- Check if branch exists locally or on remotes
 686		local exists_local = branch_exists_local(git_dir, branch)
 687		local remotes = find_branch_remotes(git_dir, branch)
 688
 689		if not exists_local and #remotes == 0 then
 690			die("branch '" .. branch .. "' not found locally or on any remote (use -b to create)")
 691		end
 692
 693		if #remotes > 1 then
 694			die("branch '" .. branch .. "' exists on multiple remotes: " .. table.concat(remotes, ", "))
 695		end
 696
 697		output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. target_path .. " " .. branch)
 698	end
 699
 700	if code ~= 0 then
 701		die("failed to add worktree: " .. output, EXIT_SYSTEM_ERROR)
 702	end
 703
 704	-- Run hooks if we have a source worktree
 705	local project_config = load_project_config(root)
 706	if source_worktree then
 707		if project_config.hooks then
 708			run_hooks(source_worktree, target_path, project_config.hooks, root)
 709		end
 710	elseif project_config.hooks then
 711		io.stderr:write("warning: hooks skipped (run from inside a worktree to apply hooks)\n")
 712	end
 713
 714	print(target_path)
 715end
 716
 717---Check if cwd is inside (or equal to) a given path
 718---@param target string
 719---@return boolean
 720local function cwd_inside_path(target)
 721	local cwd = get_cwd()
 722	if not cwd then
 723		return false
 724	end
 725	return path_inside(cwd, target)
 726end
 727
 728---Get the bare repo's HEAD branch
 729---@param git_dir string
 730---@return string|nil branch name, nil on error
 731local function get_bare_head(git_dir)
 732	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git symbolic-ref --short HEAD")
 733	if code ~= 0 then
 734		return nil
 735	end
 736	return (output:gsub("%s+$", ""))
 737end
 738
 739---@param args string[]
 740local function cmd_remove(args)
 741	-- Parse arguments: <branch> [-b] [-f]
 742	local branch = nil
 743	local delete_branch = false
 744	local force = false
 745
 746	for _, a in ipairs(args) do
 747		if a == "-b" then
 748			delete_branch = true
 749		elseif a == "-f" then
 750			force = true
 751		elseif not branch then
 752			branch = a
 753		else
 754			die("unexpected argument: " .. a)
 755		end
 756	end
 757
 758	if not branch then
 759		die("usage: wt r <branch> [-b] [-f]")
 760		return
 761	end
 762
 763	local root, err = find_project_root()
 764	if not root then
 765		die(err --[[@as string]])
 766		return
 767	end
 768
 769	local git_dir = root .. "/.bare"
 770
 771	-- Find worktree by querying git for actual location (not computed from config)
 772	local wt_output, wt_code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
 773	if wt_code ~= 0 then
 774		die("failed to list worktrees", EXIT_SYSTEM_ERROR)
 775		return
 776	end
 777
 778	local worktrees = parse_worktree_list(wt_output)
 779	local target_path = nil
 780	for _, wt in ipairs(worktrees) do
 781		if wt.branch == branch then
 782			target_path = wt.path
 783			break
 784		end
 785	end
 786
 787	if not target_path then
 788		die("no worktree found for branch '" .. branch .. "'")
 789		return
 790	end
 791
 792	-- Error if cwd is inside the worktree
 793	if cwd_inside_path(target_path) then
 794		die("cannot remove worktree while inside it")
 795	end
 796
 797	-- Check for uncommitted changes
 798	if not force then
 799		local status_out = run_cmd("git -C " .. target_path .. " status --porcelain")
 800		if status_out ~= "" then
 801			die("worktree has uncommitted changes (use -f to force)")
 802		end
 803	end
 804
 805	-- Remove worktree
 806	local remove_cmd = "GIT_DIR=" .. git_dir .. " git worktree remove"
 807	if force then
 808		remove_cmd = remove_cmd .. " --force"
 809	end
 810	remove_cmd = remove_cmd .. " -- " .. target_path
 811
 812	local output, code = run_cmd(remove_cmd)
 813	if code ~= 0 then
 814		die("failed to remove worktree: " .. output, EXIT_SYSTEM_ERROR)
 815	end
 816
 817	-- Delete branch if requested
 818	if delete_branch then
 819		-- Check if branch is bare repo's HEAD
 820		local bare_head = get_bare_head(git_dir)
 821		if bare_head and bare_head == branch then
 822			io.stderr:write("warning: cannot delete branch '" .. branch .. "' (it's the bare repo's HEAD)\n")
 823			print("Worktree removed; branch retained")
 824			return
 825		end
 826
 827		-- Check if branch is checked out elsewhere
 828		local checked_out = branch_checked_out_at(git_dir, branch)
 829		if checked_out then
 830			die("cannot delete branch '" .. branch .. "': checked out at " .. checked_out)
 831		end
 832
 833		-- Delete branch
 834		local delete_flag = force and "-D" or "-d"
 835		local del_output, del_code = run_cmd("GIT_DIR=" .. git_dir .. " git branch " .. delete_flag .. " " .. branch)
 836		if del_code ~= 0 then
 837			io.stderr:write("warning: failed to delete branch: " .. del_output .. "\n")
 838			print("Worktree removed; branch retained")
 839			return
 840		end
 841
 842		print("Worktree and branch '" .. branch .. "' removed")
 843	else
 844		print("Worktree removed")
 845	end
 846end
 847
 848local function cmd_list()
 849	local root, err = find_project_root()
 850	if not root then
 851		die(err --[[@as string]])
 852		return
 853	end
 854
 855	local git_dir = root .. "/.bare"
 856	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
 857	if code ~= 0 then
 858		die("failed to list worktrees: " .. output, EXIT_SYSTEM_ERROR)
 859	end
 860
 861	-- Parse porcelain output into worktree entries
 862	---@type {path: string, head: string, branch: string}[]
 863	local worktrees = {}
 864	local current = {}
 865
 866	for line in output:gmatch("[^\n]+") do
 867		local key, value = line:match("^(%S+)%s*(.*)$")
 868		if key == "worktree" and value then
 869			if current.path then
 870				table.insert(worktrees, current)
 871			end
 872			-- Skip .bare directory
 873			if value:match("/%.bare$") then
 874				current = {}
 875			else
 876				current = { path = value, head = "", branch = "(detached)" }
 877			end
 878		elseif key == "HEAD" and value then
 879			current.head = value:sub(1, 7)
 880		elseif key == "branch" and value then
 881			current.branch = value:gsub("^refs/heads/", "")
 882		elseif key == "bare" then
 883			-- Skip bare repo entry
 884			current = {}
 885		end
 886	end
 887	if current.path then
 888		table.insert(worktrees, current)
 889	end
 890
 891	if #worktrees == 0 then
 892		print("No worktrees found")
 893		return
 894	end
 895
 896	-- Get current working directory
 897	local cwd = get_cwd() or ""
 898
 899	-- Build table rows with status
 900	local rows = {}
 901	for _, wt in ipairs(worktrees) do
 902		local rel_path = relative_path(cwd, wt.path)
 903
 904		-- Check dirty status
 905		local status_out = run_cmd("git -C " .. wt.path .. " status --porcelain")
 906		local status = status_out == "" and "clean" or "dirty"
 907
 908		table.insert(rows, rel_path .. "," .. wt.branch .. "," .. wt.head .. "," .. status)
 909	end
 910
 911	-- Output via gum table
 912	local table_input = "Path,Branch,HEAD,Status\n" .. table.concat(rows, "\n")
 913	table_input = table_input:gsub("EOF", "eof")
 914	local table_cmd = "gum table --print <<'EOF'\n" .. table_input .. "\nEOF"
 915	local table_handle = io.popen(table_cmd, "r")
 916	if not table_handle then
 917		return
 918	end
 919	io.write(table_handle:read("*a") or "")
 920	table_handle:close()
 921end
 922
 923local function cmd_fetch()
 924	local root, err = find_project_root()
 925	if not root then
 926		die(err --[[@as string]])
 927		return
 928	end
 929
 930	local git_dir = root .. "/.bare"
 931	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all --prune")
 932	io.write(output)
 933	if code ~= 0 then
 934		os.exit(EXIT_SYSTEM_ERROR)
 935	end
 936end
 937
 938---List directory entries (excluding . and ..)
 939---@param path string
 940---@return string[]
 941local function list_dir(path)
 942	local entries = {}
 943	local handle = io.popen("ls -A " .. path .. " 2>/dev/null")
 944	if not handle then
 945		return entries
 946	end
 947	for line in handle:lines() do
 948		if line ~= "" then
 949			table.insert(entries, line)
 950		end
 951	end
 952	handle:close()
 953	return entries
 954end
 955
 956---Check if path is a directory
 957---@param path string
 958---@return boolean
 959local function is_dir(path)
 960	local f = io.open(path, "r")
 961	if not f then
 962		return false
 963	end
 964	f:close()
 965	return run_cmd_silent("test -d " .. path)
 966end
 967
 968---Check if path is a file (not directory)
 969---@param path string
 970---@return boolean
 971local function is_file(path)
 972	local f = io.open(path, "r")
 973	if not f then
 974		return false
 975	end
 976	f:close()
 977	return run_cmd_silent("test -f " .. path)
 978end
 979
 980---@param args string[]
 981local function cmd_init(args)
 982	-- Parse arguments
 983	local dry_run = false
 984	local skip_confirm = false
 985	for _, a in ipairs(args) do
 986		if a == "--dry-run" then
 987			dry_run = true
 988		elseif a == "-y" or a == "--yes" then
 989			skip_confirm = true
 990		else
 991			die("unexpected argument: " .. a)
 992		end
 993	end
 994
 995	local cwd = get_cwd()
 996	if not cwd then
 997		die("failed to get current directory", EXIT_SYSTEM_ERROR)
 998		return
 999	end
1000
1001	-- Case 1: Already wt-managed (.git file pointing to .bare + .bare/ exists)
1002	local git_path = cwd .. "/.git"
1003	local bare_path = cwd .. "/.bare"
1004
1005	local bare_exists = is_dir(bare_path)
1006	local git_file = io.open(git_path, "r")
1007
1008	if git_file then
1009		local content = git_file:read("*a")
1010		git_file:close()
1011
1012		-- Check if it's a file (not directory) pointing to .bare
1013		if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then
1014			if bare_exists then
1015				print("Already using wt bare structure")
1016				os.exit(EXIT_SUCCESS)
1017			end
1018		end
1019
1020		-- Check if .git is a file pointing elsewhere (inside a worktree)
1021		if is_file(git_path) and content and content:match("^gitdir:") then
1022			-- It's a worktree, not project root
1023			die("inside a worktree; run from project root or use 'wt c' to clone fresh")
1024		end
1025	end
1026
1027	-- Check for .git directory
1028	local git_dir_exists = is_dir(git_path)
1029
1030	if not git_dir_exists then
1031		-- Case 5: No .git at all, or bare repo without .git dir
1032		if bare_exists then
1033			die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'")
1034		end
1035		die("not a git repository (no .git found)")
1036	end
1037
1038	-- Now we have a .git directory
1039	-- Case 3: Existing worktree setup (.git/worktrees/ exists)
1040	local worktrees_path = git_path .. "/worktrees"
1041	if is_dir(worktrees_path) then
1042		local worktrees = list_dir(worktrees_path)
1043		io.stderr:write("error: repository already uses git worktrees\n")
1044		io.stderr:write("\n")
1045		io.stderr:write("Converting an existing worktree setup is complex and error-prone.\n")
1046		io.stderr:write("Recommend: clone fresh with 'wt c <url>' and recreate worktrees.\n")
1047		if #worktrees > 0 then
1048			io.stderr:write("\nExisting worktrees:\n")
1049			for _, wt in ipairs(worktrees) do
1050				io.stderr:write("  " .. wt .. "\n")
1051			end
1052		end
1053		os.exit(EXIT_USER_ERROR)
1054	end
1055
1056	-- Case 4: Normal clone (.git/ directory, no worktrees)
1057	-- Check for uncommitted changes
1058	local status_out = run_cmd("git status --porcelain")
1059	if status_out ~= "" then
1060		die("uncommitted changes; commit or stash before converting")
1061	end
1062
1063	-- Detect default branch
1064	local default_branch = detect_cloned_default_branch(git_path)
1065
1066	-- Warnings
1067	local warnings = {}
1068
1069	-- Check for submodules
1070	if is_file(cwd .. "/.gitmodules") then
1071		table.insert(warnings, "submodules detected (.gitmodules exists); verify they work after conversion")
1072	end
1073
1074	-- Check for nested .git directories (excluding the main one)
1075	local nested_git_output, _ = run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null")
1076	if nested_git_output ~= "" then
1077		table.insert(warnings, "nested .git directories found; these may cause issues")
1078	end
1079
1080	-- Find orphaned files (files in root that will be deleted)
1081	local all_entries = list_dir(cwd)
1082	local orphaned = {}
1083	for _, entry in ipairs(all_entries) do
1084		if entry ~= ".git" and entry ~= ".bare" then
1085			table.insert(orphaned, entry)
1086		end
1087	end
1088
1089	-- Load global config for path style
1090	local global_config = load_global_config()
1091	local style = global_config.branch_path_style or "nested"
1092	local separator = global_config.flat_separator
1093	local worktree_path = branch_to_path(cwd, default_branch, style, separator)
1094
1095	if dry_run then
1096		print("Dry run - planned actions:")
1097		print("")
1098		print("1. Move .git/ to .bare/")
1099		print("2. Create .git file pointing to .bare/")
1100		print("3. Create worktree: " .. worktree_path .. " (" .. default_branch .. ")")
1101		if #orphaned > 0 then
1102			print("4. Remove " .. #orphaned .. " orphaned items from root:")
1103			for _, item in ipairs(orphaned) do
1104				print("   - " .. item)
1105			end
1106		end
1107		if #warnings > 0 then
1108			print("")
1109			print("Warnings:")
1110			for _, w in ipairs(warnings) do
1111				print("" .. w)
1112			end
1113		end
1114		os.exit(EXIT_SUCCESS)
1115	end
1116
1117	-- Show warnings
1118	for _, w in ipairs(warnings) do
1119		io.stderr:write("warning: " .. w .. "\n")
1120	end
1121
1122	-- Confirm with gum (unless -y/--yes)
1123	if not skip_confirm then
1124		local confirm_msg = "Convert to wt bare structure? This will move .git to .bare"
1125		if #orphaned > 0 then
1126			confirm_msg = confirm_msg .. " and remove " .. #orphaned .. " items from root"
1127		end
1128
1129		local confirm_code = os.execute("gum confirm '" .. confirm_msg .. "'")
1130		if confirm_code ~= true then
1131			print("Aborted")
1132			os.exit(EXIT_USER_ERROR)
1133		end
1134	end
1135
1136	-- Step 1: Move .git to .bare
1137	local output, code = run_cmd("mv " .. git_path .. " " .. bare_path)
1138	if code ~= 0 then
1139		die("failed to move .git to .bare: " .. output, EXIT_SYSTEM_ERROR)
1140	end
1141
1142	-- Step 2: Write .git file
1143	local git_file_handle = io.open(git_path, "w")
1144	if not git_file_handle then
1145		-- Try to recover
1146		run_cmd("mv " .. bare_path .. " " .. git_path)
1147		die("failed to create .git file", EXIT_SYSTEM_ERROR)
1148		return
1149	end
1150	git_file_handle:write("gitdir: ./.bare\n")
1151	git_file_handle:close()
1152
1153	-- Step 3: Detach HEAD so branch can be checked out in worktree
1154	-- Point bare repo's HEAD to a placeholder so main branch can be used by worktree
1155	run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__")
1156
1157	-- Step 4: Create worktree for default branch
1158	output, code = run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
1159	if code ~= 0 then
1160		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
1161	end
1162
1163	-- Step 5: Remove orphaned files from root
1164	for _, item in ipairs(orphaned) do
1165		local item_path = cwd .. "/" .. item
1166		output, code = run_cmd("rm -rf " .. item_path)
1167		if code ~= 0 then
1168			io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n")
1169		end
1170	end
1171
1172	-- Summary
1173	print("Converted to wt bare structure")
1174	print("Bare repo:  " .. bare_path)
1175	print("Worktree:   " .. worktree_path)
1176	if #orphaned > 0 then
1177		print("Removed:    " .. #orphaned .. " items from root")
1178	end
1179end
1180
1181-- Main entry point
1182
1183local function main()
1184	local command = arg[1]
1185
1186	if not command or command == "help" or command == "--help" or command == "-h" then
1187		print_usage()
1188		os.exit(EXIT_SUCCESS)
1189	end
1190
1191	---@cast command string
1192
1193	-- Collect remaining args
1194	local subargs = {}
1195	for i = 2, #arg do
1196		table.insert(subargs, arg[i])
1197	end
1198
1199	-- Check for --help on any command
1200	if subargs[1] == "--help" or subargs[1] == "-h" then
1201		show_command_help(command)
1202	end
1203
1204	if command == "c" then
1205		cmd_clone(subargs)
1206	elseif command == "n" then
1207		cmd_new(subargs)
1208	elseif command == "a" then
1209		cmd_add(subargs)
1210	elseif command == "r" then
1211		cmd_remove(subargs)
1212	elseif command == "l" then
1213		cmd_list()
1214	elseif command == "f" then
1215		cmd_fetch()
1216	elseif command == "init" then
1217		cmd_init(subargs)
1218	else
1219		die("unknown command: " .. command)
1220	end
1221end
1222
1223-- Export for testing when required as module
1224if pcall(debug.getlocal, 4, 1) then
1225	return {
1226		-- URL/project parsing
1227		extract_project_name = extract_project_name,
1228		resolve_url_template = resolve_url_template,
1229		-- Path manipulation
1230		branch_to_path = branch_to_path,
1231		split_path = split_path,
1232		relative_path = relative_path,
1233		path_inside = path_inside,
1234		-- Config loading
1235		load_global_config = load_global_config,
1236		load_project_config = load_project_config,
1237		-- Git output parsing (testable without git)
1238		parse_branch_remotes = parse_branch_remotes,
1239		parse_worktree_list = parse_worktree_list,
1240		escape_pattern = escape_pattern,
1241		-- Hook helpers (re-exported from wt.hooks)
1242		summarize_hooks = summarize_hooks,
1243		load_hook_permissions = load_hook_permissions,
1244		save_hook_permissions = save_hook_permissions,
1245		run_hooks = run_hooks,
1246		-- Project root detection (re-exported from wt.git)
1247		find_project_root = find_project_root,
1248		detect_source_worktree = detect_source_worktree,
1249		-- Command execution (for integration tests)
1250		run_cmd = run_cmd,
1251		run_cmd_silent = run_cmd_silent,
1252		-- Exit codes (re-exported from wt.exit)
1253		EXIT_SUCCESS = exit.EXIT_SUCCESS,
1254		EXIT_USER_ERROR = exit.EXIT_USER_ERROR,
1255		EXIT_SYSTEM_ERROR = exit.EXIT_SYSTEM_ERROR,
1256	}
1257end
1258
1259main()