refactor(wt): extract git module

Amolith created

Assisted-by: Claude Opus 4.5 via Amp

Change summary

src/main.lua   | 257 ++-------------------------------------------------
src/wt/git.lua | 214 +++++++++++++++++++++++++++++++++++++++++++
2 files changed, 227 insertions(+), 244 deletions(-)

Detailed changes

src/main.lua 🔗

@@ -27,46 +27,16 @@ local relative_path = path_mod.relative_path
 local path_inside = path_mod.path_inside
 local escape_pattern = path_mod.escape_pattern
 
----Walk up from cwd looking for .git file pointing to .bare, or .bare/ directory
----@return string|nil root
----@return string|nil error
-local function find_project_root()
-	local handle = io.popen("pwd")
-	if not handle then
-		return nil, "failed to get current directory"
-	end
-	local cwd = handle:read("*l")
-	handle:close()
-
-	if not cwd then
-		return nil, "failed to get current directory"
-	end
-
-	local path = cwd
-	while path and path ~= "" and path ~= "/" do
-		-- Check for .bare directory
-		local bare_check = io.open(path .. "/.bare/HEAD", "r")
-		if bare_check then
-			bare_check:close()
-			return path, nil
-		end
-
-		-- Check for .git file pointing to .bare
-		local git_file = io.open(path .. "/.git", "r")
-		if git_file then
-			local content = git_file:read("*a")
-			git_file:close()
-			if content and content:match("gitdir:%s*%.?/?%.bare") then
-				return path, nil
-			end
-		end
-
-		-- Move up one directory
-		path = path:match("(.+)/[^/]+$")
-	end
-
-	return nil, "not in a wt-managed repository"
-end
+local git_mod = require("wt.git")
+local find_project_root = git_mod.find_project_root
+local detect_source_worktree = git_mod.detect_source_worktree
+local branch_exists_local = git_mod.branch_exists_local
+local find_branch_remotes = git_mod.find_branch_remotes
+local detect_cloned_default_branch = git_mod.detect_cloned_default_branch
+local get_default_branch = git_mod.get_default_branch
+local parse_branch_remotes = git_mod.parse_branch_remotes
+local parse_worktree_list = git_mod.parse_worktree_list
+local branch_checked_out_at = git_mod.branch_checked_out_at
 
 ---Substitute ${project} in template string
 ---@param template string
@@ -281,31 +251,6 @@ local function extract_project_name(url)
 	return name
 end
 
----Detect default branch from cloned bare repo
----@param git_dir string
----@return string
-local function detect_cloned_default_branch(git_dir)
-	-- First try the bare repo's own HEAD (set during clone)
-	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git symbolic-ref HEAD")
-	if code == 0 and output ~= "" then
-		local branch = output:match("refs/heads/(.+)")
-		if branch then
-			return (branch:gsub("%s+$", ""))
-		end
-	end
-	return "main"
-end
-
----Get default branch name from git config, fallback to "main"
----@return string
-local function get_default_branch()
-	local output, code = run_cmd("git config --get init.defaultBranch")
-	if code == 0 and output ~= "" then
-		return (output:gsub("%s+$", ""))
-	end
-	return "main"
-end
-
 ---Load global config from ~/.config/wt/config.lua
 ---@return {branch_path_style?: string, flat_separator?: string, remotes?: table<string, string>, default_remotes?: string[]|string}
 local function load_global_config()
@@ -372,86 +317,6 @@ local function load_project_config(root)
 	return result
 end
 
----Check if cwd is inside a worktree (has .git file, not at project root)
----@param root string
----@return string|nil source_worktree path if inside worktree, nil if at project root
-local function detect_source_worktree(root)
-	local cwd = get_cwd()
-	if not cwd then
-		return nil
-	end
-	-- If cwd is the project root, no source worktree
-	if cwd == root then
-		return nil
-	end
-	-- Check if cwd has a .git file (indicating it's a worktree)
-	local git_file = io.open(cwd .. "/.git", "r")
-	if git_file then
-		git_file:close()
-		return cwd
-	end
-	-- Walk up to find worktree root
-	---@type string|nil
-	local path = cwd
-	while path and path ~= "" and path ~= "/" and path ~= root do
-		local gf = io.open(path .. "/.git", "r")
-		if gf then
-			gf:close()
-			return path
-		end
-		path = path:match("(.+)/[^/]+$")
-	end
-	return nil
-end
-
----Check if branch exists locally
----@param git_dir string
----@param branch string
----@return boolean
-local function branch_exists_local(git_dir, branch)
-	return run_cmd_silent("GIT_DIR=" .. git_dir .. " git show-ref --verify --quiet refs/heads/" .. branch)
-end
-
----Parse git branch -r output to extract remotes containing a branch
----@param output string git branch -r output
----@param branch string branch name to find
----@return string[] remote names
-local function parse_branch_remotes(output, branch)
-	local remotes = {}
-	for line in output:gmatch("[^\n]+") do
-		-- Match: "  origin/branch-name" or "  upstream/feature/foo"
-		-- For branch "feature/foo", we want remote "origin", not "origin/feature"
-		-- The remote name is everything before the LAST occurrence of /branch
-		local trimmed = line:match("^%s*(.-)%s*$")
-		if trimmed then
-			-- Check if line ends with /branch
-			local suffix = "/" .. branch
-			if trimmed:sub(-#suffix) == suffix then
-				local remote = trimmed:sub(1, #trimmed - #suffix)
-				-- Simple remote name (no slashes) - this is what we want
-				-- Remote names with slashes (e.g., "forks/alice") are ambiguous
-				-- and skipped for safety
-				if remote ~= "" and not remote:match("/") then
-					table.insert(remotes, remote)
-				end
-			end
-		end
-	end
-	return remotes
-end
-
----Find which remotes have the branch
----@param git_dir string
----@param branch string
----@return string[] remote names
-local function find_branch_remotes(git_dir, branch)
-	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git branch -r --list '*/" .. branch .. "'")
-	if code ~= 0 then
-		return {}
-	end
-	return parse_branch_remotes(output, branch)
-end
-
 ---Load hook permissions from ~/.local/share/wt/hook-dirs.lua
 ---@return table<string, boolean>
 local function load_hook_permissions()
@@ -1147,56 +1012,6 @@ local function get_bare_head(git_dir)
 	return (output:gsub("%s+$", ""))
 end
 
----Parse git worktree list --porcelain output
----@param output string git worktree list --porcelain output
----@return table[] array of {path: string, branch?: string, bare?: boolean, detached?: boolean}
-local function parse_worktree_list(output)
-	local worktrees = {}
-	---@type {path: string, branch?: string, bare?: boolean, detached?: boolean, head?: string}|nil
-	local current = nil
-	for line in output:gmatch("[^\n]+") do
-		local key, value = line:match("^(%S+)%s*(.*)$")
-		if key == "worktree" and value then
-			if current then
-				table.insert(worktrees, current)
-			end
-			current = { path = value }
-		elseif current then
-			if key == "branch" and value then
-				current.branch = value:gsub("^refs/heads/", "")
-			elseif key == "bare" then
-				current.bare = true
-			elseif key == "detached" then
-				current.detached = true
-			elseif key == "HEAD" then
-				current.head = value
-			end
-		end
-	end
-	if current then
-		table.insert(worktrees, current)
-	end
-	return worktrees
-end
-
----Check if branch is checked out in any worktree
----@param git_dir string
----@param branch string
----@return string|nil path if checked out, nil otherwise
-local function branch_checked_out_at(git_dir, branch)
-	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
-	if code ~= 0 then
-		return nil
-	end
-	local worktrees = parse_worktree_list(output)
-	for _, wt in ipairs(worktrees) do
-		if wt.branch == branch then
-			return wt.path
-		end
-	end
-	return nil
-end
-
 ---@param args string[]
 local function cmd_remove(args)
 	-- Parse arguments: <branch> [-b] [-f]
@@ -1793,55 +1608,9 @@ if pcall(debug.getlocal, 4, 1) then
 				end
 			end
 		end,
-		-- Project root detection
-		find_project_root = function(cwd_override)
-			local cwd = cwd_override or get_cwd()
-			if not cwd then
-				return nil, "failed to get current directory"
-			end
-			local path = cwd
-			while path and path ~= "" and path ~= "/" do
-				local bare_check = io.open(path .. "/.bare/HEAD", "r")
-				if bare_check then
-					bare_check:close()
-					return path, nil
-				end
-				local git_file = io.open(path .. "/.git", "r")
-				if git_file then
-					local content = git_file:read("*a")
-					git_file:close()
-					if content and content:match("gitdir:%s*%.?/?%.bare") then
-						return path, nil
-					end
-				end
-				path = path:match("(.+)/[^/]+$")
-			end
-			return nil, "not in a wt-managed repository"
-		end,
-		detect_source_worktree = function(root, cwd_override)
-			local cwd = cwd_override or get_cwd()
-			if not cwd then
-				return nil
-			end
-			if cwd == root then
-				return nil
-			end
-			local git_file = io.open(cwd .. "/.git", "r")
-			if git_file then
-				git_file:close()
-				return cwd
-			end
-			local path = cwd
-			while path and path ~= "" and path ~= "/" and path ~= root do
-				local gf = io.open(path .. "/.git", "r")
-				if gf then
-					gf:close()
-					return path
-				end
-				path = path:match("(.+)/[^/]+$")
-			end
-			return nil
-		end,
+		-- Project root detection (re-exported from wt.git)
+		find_project_root = find_project_root,
+		detect_source_worktree = detect_source_worktree,
 		-- Command execution (for integration tests)
 		run_cmd = run_cmd,
 		run_cmd_silent = run_cmd_silent,

src/wt/git.lua 🔗

@@ -0,0 +1,214 @@
+-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+--
+-- SPDX-License-Identifier: GPL-3.0-or-later
+
+local shell = require("wt.shell")
+local run_cmd = shell.run_cmd
+local run_cmd_silent = shell.run_cmd_silent
+local get_cwd = shell.get_cwd
+
+---@class wt.git
+local M = {}
+
+---Walk up from cwd looking for .git file pointing to .bare, or .bare/ directory
+---@param cwd_override? string optional starting directory (defaults to pwd)
+---@return string|nil root
+---@return string|nil error
+function M.find_project_root(cwd_override)
+	local cwd = cwd_override
+	if not cwd then
+		local handle = io.popen("pwd")
+		if not handle then
+			return nil, "failed to get current directory"
+		end
+		cwd = handle:read("*l")
+		handle:close()
+	end
+
+	if not cwd then
+		return nil, "failed to get current directory"
+	end
+
+	local path = cwd
+	while path and path ~= "" and path ~= "/" do
+		-- Check for .bare directory
+		local bare_check = io.open(path .. "/.bare/HEAD", "r")
+		if bare_check then
+			bare_check:close()
+			return path, nil
+		end
+
+		-- Check for .git file pointing to .bare
+		local git_file = io.open(path .. "/.git", "r")
+		if git_file then
+			local content = git_file:read("*a")
+			git_file:close()
+			if content and content:match("gitdir:%s*%.?/?%.bare") then
+				return path, nil
+			end
+		end
+
+		-- Move up one directory
+		path = path:match("(.+)/[^/]+$")
+	end
+
+	return nil, "not in a wt-managed repository"
+end
+
+---Check if cwd is inside a worktree (has .git file, not at project root)
+---@param root string
+---@param cwd_override? string optional current directory (defaults to get_cwd())
+---@return string|nil source_worktree path if inside worktree, nil if at project root
+function M.detect_source_worktree(root, cwd_override)
+	local cwd = cwd_override or get_cwd()
+	if not cwd then
+		return nil
+	end
+	-- If cwd is the project root, no source worktree
+	if cwd == root then
+		return nil
+	end
+	-- Check if cwd has a .git file (indicating it's a worktree)
+	local git_file = io.open(cwd .. "/.git", "r")
+	if git_file then
+		git_file:close()
+		return cwd
+	end
+	-- Walk up to find worktree root
+	---@type string|nil
+	local path = cwd
+	while path and path ~= "" and path ~= "/" and path ~= root do
+		local gf = io.open(path .. "/.git", "r")
+		if gf then
+			gf:close()
+			return path
+		end
+		path = path:match("(.+)/[^/]+$")
+	end
+	return nil
+end
+
+---Check if branch exists locally
+---@param git_dir string
+---@param branch string
+---@return boolean
+function M.branch_exists_local(git_dir, branch)
+	return run_cmd_silent("GIT_DIR=" .. git_dir .. " git show-ref --verify --quiet refs/heads/" .. branch)
+end
+
+---Parse git branch -r output to extract remotes containing a branch
+---@param output string git branch -r output
+---@param branch string branch name to find
+---@return string[] remote names
+function M.parse_branch_remotes(output, branch)
+	local remotes = {}
+	for line in output:gmatch("[^\n]+") do
+		-- Match: "  origin/branch-name" or "  upstream/feature/foo"
+		-- For branch "feature/foo", we want remote "origin", not "origin/feature"
+		-- The remote name is everything before the LAST occurrence of /branch
+		local trimmed = line:match("^%s*(.-)%s*$")
+		if trimmed then
+			-- Check if line ends with /branch
+			local suffix = "/" .. branch
+			if trimmed:sub(-#suffix) == suffix then
+				local remote = trimmed:sub(1, #trimmed - #suffix)
+				-- Simple remote name (no slashes) - this is what we want
+				-- Remote names with slashes (e.g., "forks/alice") are ambiguous
+				-- and skipped for safety
+				if remote ~= "" and not remote:match("/") then
+					table.insert(remotes, remote)
+				end
+			end
+		end
+	end
+	return remotes
+end
+
+---Find which remotes have the branch
+---@param git_dir string
+---@param branch string
+---@return string[] remote names
+function M.find_branch_remotes(git_dir, branch)
+	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git branch -r --list '*/" .. branch .. "'")
+	if code ~= 0 then
+		return {}
+	end
+	return M.parse_branch_remotes(output, branch)
+end
+
+---Detect default branch from cloned bare repo's HEAD
+---@param git_dir string
+---@return string branch name
+function M.detect_cloned_default_branch(git_dir)
+	-- First try the bare repo's own HEAD (set during clone)
+	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git symbolic-ref HEAD")
+	if code == 0 and output ~= "" then
+		local branch = output:match("refs/heads/(.+)")
+		if branch then
+			return (branch:gsub("%s+$", ""))
+		end
+	end
+	return "main"
+end
+
+---Get default branch name from git config, fallback to "main"
+---@return string
+function M.get_default_branch()
+	local output, code = run_cmd("git config --get init.defaultBranch")
+	if code == 0 and output ~= "" then
+		return (output:gsub("%s+$", ""))
+	end
+	return "main"
+end
+
+---Parse git worktree list --porcelain output
+---@param output string
+---@return {path: string, branch?: string, bare?: boolean, detached?: boolean, head?: string}[]
+function M.parse_worktree_list(output)
+	local worktrees = {}
+	---@type {path: string, branch?: string, bare?: boolean, detached?: boolean, head?: string}|nil
+	local current = nil
+	for line in output:gmatch("[^\n]+") do
+		local key, value = line:match("^(%S+)%s*(.*)$")
+		if key == "worktree" and value then
+			if current then
+				table.insert(worktrees, current)
+			end
+			current = { path = value }
+		elseif current then
+			if key == "branch" and value then
+				current.branch = value:gsub("^refs/heads/", "")
+			elseif key == "bare" then
+				current.bare = true
+			elseif key == "detached" then
+				current.detached = true
+			elseif key == "HEAD" then
+				current.head = value
+			end
+		end
+	end
+	if current then
+		table.insert(worktrees, current)
+	end
+	return worktrees
+end
+
+---Check if branch is checked out in any worktree
+---@param git_dir string
+---@param branch string
+---@return string|nil path if checked out, nil otherwise
+function M.branch_checked_out_at(git_dir, branch)
+	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
+	if code ~= 0 then
+		return nil
+	end
+	local worktrees = M.parse_worktree_list(output)
+	for _, wt in ipairs(worktrees) do
+		if wt.branch == branch then
+			return wt.path
+		end
+	end
+	return nil
+end
+
+return M