Files
nvim/lua/git/status_win.lua
T

951 lines
28 KiB
Lua

local diff = require("git.diff")
local log = require("log")
local repo = require("git.repo")
local M = {}
local SECTIONS = {
"Untracked",
"Unstaged",
"Staged",
"Unmerged",
"Unpushed",
"Unpulled",
}
local SIDEBAR_WIDTH = 50
---@class ow.Git.FileEntry
---@field section string
---@field path string
---@field orig string?
---@field x string porcelain v1 column 1 (always set; may be a literal space)
---@field y string porcelain v1 column 2 (always set; may be a literal space)
---@class ow.Git.CommitEntry
---@field section string
---@field sha string
---@field subject string?
---@alias ow.Git.StatusEntry ow.Git.FileEntry | ow.Git.CommitEntry
---@class ow.Git.StatusState
---@field gitdir string
---@field worktree string
---@field lines table<integer, ow.Git.StatusEntry>
---@field sidebar_win integer?
---@field diff_left_win integer?
---@field diff_right_win integer?
---@field user_aucmd integer?
---@field last_shown_key string?
---@field last_render_key string? fingerprint of the last rendered state
---@type table<integer, ow.Git.StatusState>
local state = {}
local group =
vim.api.nvim_create_augroup("ow.git.status_win", { clear = false })
local ns = vim.api.nvim_create_namespace("ow.git.status_win")
---Find the sidebar window in the current tabpage by filetype. Used as a
---fallback when we don't have a `StatusState` handy (e.g. M.toggle).
---@return integer? win
---@return integer? bufnr
local function find_sidebar()
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
local buf = vim.api.nvim_win_get_buf(win)
if vim.bo[buf].filetype == "gitstatus" then
return win, buf
end
end
end
---Return the sidebar window stashed on `s`, validating that it's still
---live. Falls back to `find_sidebar` if the stashed handle is gone.
---@param s ow.Git.StatusState
---@return integer?
local function sidebar_win_for(s)
local win = s.sidebar_win
if win and vim.api.nvim_win_is_valid(win) then
return win
end
win = find_sidebar()
s.sidebar_win = win
return win
end
---@param entry ow.Git.StatusEntry
---@return string?
local function entry_code(entry)
if entry.section == "Untracked" then
return "??"
elseif entry.section == "Unmerged" then
return entry.x .. entry.y
elseif entry.section == "Staged" then
return entry.x .. " "
elseif entry.section == "Unstaged" then
return " " .. entry.y
end
end
---@param entry ow.Git.StatusEntry
---@return string? line
---@return string? hl_group
---@return integer? hl_len byte length of the symbol portion at column 2
local function format_entry(entry)
if entry.sha then
return string.format(" %s %s", entry.sha, entry.subject or ""),
"GitSha",
#entry.sha
end
local code = entry_code(entry)
if not code then
return nil
end
local char, hl = repo.indicator(code)
if not char then
return nil
end
local label = entry.orig and (entry.orig .. " -> " .. entry.path)
or entry.path
return string.format(" %s %s", char, label), hl, #char
end
---@class ow.Git.BranchInfo
---@field head string?
---@field upstream string?
---@field ahead integer
---@field behind integer
---@param line string '## branch.line' from porcelain v1
---@return ow.Git.BranchInfo
local function parse_branch_line(line)
local info = { ahead = 0, behind = 0 }
local content = line:sub(4)
local arrow = content:find("...", 1, true)
if not arrow then
info.head = content
return info
end
info.head = content:sub(1, arrow - 1)
local rest = content:sub(arrow + 3)
local bracket = rest:find(" %[")
if not bracket then
info.upstream = rest
return info
end
info.upstream = rest:sub(1, bracket - 1)
local inside = rest:match("%[([^%]]+)%]")
if inside then
info.ahead = (tonumber(inside:match("ahead (%d+)")) or 0) --[[@as integer]]
info.behind = (tonumber(inside:match("behind (%d+)")) or 0) --[[@as integer]]
end
return info
end
---Parse `git status --porcelain=v1 --branch` output into a (branch, groups)
---pair. `Unpushed` and `Unpulled` start empty here; ahead/behind commits are
---filled in by a follow-up `git log` once we know the upstream is set.
---@param stdout string
---@return ow.Git.BranchInfo, table<string, ow.Git.StatusEntry[]>
local function parse_porcelain(stdout)
local branch = { ahead = 0, behind = 0 }
local groups = {
Untracked = {},
Unstaged = {},
Staged = {},
Unmerged = {},
Unpushed = {},
Unpulled = {},
}
for line in stdout:gmatch("[^\r\n]+") do
if line:sub(1, 2) == "##" then
branch = parse_branch_line(line)
else
local x = line:sub(1, 1)
local y = line:sub(2, 2)
local rest = line:sub(4)
local orig
local arrow = rest:find(" -> ", 1, true)
if arrow then
orig = rest:sub(1, arrow - 1)
rest = rest:sub(arrow + 4)
end
local entry = {
section = nil,
path = rest,
orig = orig,
x = x,
y = y,
}
if x == "?" and y == "?" then
entry.section = "Untracked"
table.insert(groups.Untracked, entry)
elseif repo.UNMERGED[x .. y] then
entry.section = "Unmerged"
table.insert(groups.Unmerged, entry)
else
if x ~= " " then
table.insert(groups.Staged, {
section = "Staged",
path = entry.path,
orig = entry.orig,
x = entry.x,
y = entry.y,
})
end
if y ~= " " then
table.insert(groups.Unstaged, {
section = "Unstaged",
path = entry.path,
orig = entry.orig,
x = entry.x,
y = entry.y,
})
end
end
end
end
return branch, groups
end
---Run the ahead/behind `git log` calls for any non-zero counters and call
---`callback(branch, groups)` once they all finish (or immediately when
---there's nothing to fetch).
---@param worktree string
---@param branch ow.Git.BranchInfo
---@param groups table<string, ow.Git.StatusEntry[]>
---@param callback fun(branch: ow.Git.BranchInfo, groups: table<string, ow.Git.StatusEntry[]>)
local function enrich_with_log(worktree, branch, groups, callback)
local fetches = {}
if branch.upstream and branch.ahead > 0 then
table.insert(
fetches,
{ section = "Unpushed", range = "@{upstream}..HEAD" }
)
end
if branch.upstream and branch.behind > 0 then
table.insert(
fetches,
{ section = "Unpulled", range = "HEAD..@{upstream}" }
)
end
if #fetches == 0 then
callback(branch, groups)
return
end
local pending = #fetches
for _, f in ipairs(fetches) do
vim.system({
"git",
"log",
"--format=%h %s",
f.range,
}, { cwd = worktree, text = true }, function(log_obj)
vim.schedule(function()
if log_obj.code == 0 then
for line in (log_obj.stdout or ""):gmatch("[^\r\n]+") do
local sha, subject = line:match("^(%S+)%s+(.+)$")
if sha then
table.insert(groups[f.section], {
section = f.section,
sha = sha,
subject = subject,
})
end
end
else
log.error(
"git log %s failed: %s",
f.range,
vim.trim(log_obj.stderr or "")
)
end
pending = pending - 1
if pending == 0 then
callback(branch, groups)
end
end)
end)
end
end
---Build the (branch, groups) tuple for the sidebar. When `prefetched_stdout`
---is provided (typical case: dispatched via the `User GitRefresh` autocmd
---that already ran `git status --porcelain=v1 --branch` for the indicator),
---we skip the duplicate subprocess. Otherwise the sidebar fetches its own.
---@param worktree string
---@param prefetched_stdout string?
---@param callback fun(branch: ow.Git.BranchInfo, groups: table<string, ow.Git.StatusEntry[]>)
local function fetch_status(worktree, prefetched_stdout, callback)
if prefetched_stdout then
local branch, groups = parse_porcelain(prefetched_stdout)
enrich_with_log(worktree, branch, groups, callback)
return
end
vim.system({
"git",
"-c",
"core.quotePath=false",
"status",
"--porcelain=v1",
"--branch",
}, { cwd = worktree, text = true }, function(obj)
vim.schedule(function()
if obj.code ~= 0 then
log.error("git status failed: %s", vim.trim(obj.stderr or ""))
local branch = { ahead = 0, behind = 0 }
local groups = {
Untracked = {},
Unstaged = {},
Staged = {},
Unmerged = {},
Unpushed = {},
Unpulled = {},
}
callback(branch, groups)
return
end
local branch, groups = parse_porcelain(obj.stdout or "")
enrich_with_log(worktree, branch, groups, callback)
end)
end)
end
---@param bufnr integer
---@param branch ow.Git.BranchInfo
---@param groups table<string, ow.Git.StatusEntry[]>
local function render(bufnr, branch, groups)
local lines = { "Head: " .. (branch.head or "?") }
if branch.upstream then
local push = "Push: " .. branch.upstream
if branch.ahead > 0 then
push = push .. " +" .. branch.ahead
end
if branch.behind > 0 then
push = push .. " -" .. branch.behind
end
table.insert(lines, push)
end
table.insert(lines, "")
local meta = {}
local marks = {}
for _, section in ipairs(SECTIONS) do
local entries = groups[section]
if entries and #entries > 0 then
table.insert(lines, string.format("%s (%d)", section, #entries))
for _, entry in ipairs(entries) do
local line, hl, hl_len = format_entry(entry)
if line then
table.insert(lines, line)
meta[#lines] = entry
if hl and hl_len then
table.insert(marks, {
row = #lines - 1,
col = 2,
end_col = 2 + hl_len,
hl = hl,
})
end
end
end
table.insert(lines, "")
end
end
vim.bo[bufnr].modifiable = true
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.bo[bufnr].modifiable = false
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
for _, m in ipairs(marks) do
vim.api.nvim_buf_set_extmark(bufnr, ns, m.row, m.col, {
end_col = m.end_col,
hl_group = m.hl,
})
end
state[bufnr].lines = meta
end
---Build a stable fingerprint of the parsed branch + groups so refresh can
---short-circuit when the porcelain state is byte-identical to the last
---successful render.
---@param branch ow.Git.BranchInfo
---@param groups table<string, ow.Git.StatusEntry[]>
---@return string
local function fingerprint(branch, groups)
local parts = {
branch.head or "",
branch.upstream or "",
tostring(branch.ahead),
tostring(branch.behind),
}
for _, section in ipairs(SECTIONS) do
local entries = groups[section]
if entries then
for _, e in ipairs(entries) do
table.insert(
parts,
e.section
.. ":"
.. (e.path or e.sha or "")
.. ":"
.. (e.orig or "")
.. ":"
.. (e.x or "")
.. ":"
.. (e.y or "")
)
end
end
end
return table.concat(parts, "\0")
end
---@param bufnr integer
---@param prefetched_stdout string? porcelain output from a piggybacked GitRefresh
local function refresh(bufnr, prefetched_stdout)
local s = state[bufnr]
if not s then
return
end
local saved_path, saved_sha
local sidebar_win = sidebar_win_for(s)
if sidebar_win then
local lnum = vim.api.nvim_win_get_cursor(sidebar_win)[1]
local entry = s.lines[lnum]
if entry then
saved_path = entry.path
saved_sha = entry.sha
end
end
fetch_status(s.worktree, prefetched_stdout, function(branch, groups)
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
-- Any fs-event that triggered this refresh might have changed the
-- worktree under the diff buffers we last opened; invalidate the
-- cache so the next show_diff recomputes panes.
s.last_shown_key = nil
local fp = fingerprint(branch, groups)
if fp == s.last_render_key then
return
end
s.last_render_key = fp
render(bufnr, branch, groups)
if not saved_path and not saved_sha then
return
end
for lnum, entry in pairs(s.lines) do
if
(saved_path and entry.path == saved_path)
or (saved_sha and entry.sha == saved_sha)
then
local win = sidebar_win_for(s)
if win then
pcall(vim.api.nvim_win_set_cursor, win, { lnum, 0 })
end
break
end
end
end)
end
---@param bufnr integer
---@return ow.Git.StatusState?
---@return ow.Git.StatusEntry?
local function current_entry(bufnr)
local s = state[bufnr]
if not s then
return nil, nil
end
local lnum = vim.api.nvim_win_get_cursor(0)[1]
return s, s.lines[lnum]
end
---@class ow.Git.DiffSide
---@field buf integer
---@field name string?
---@class ow.Git.DiffPair
---@field left ow.Git.DiffSide
---@field right ow.Git.DiffSide
---@param worktree string
---@param path string
---@return ow.Git.DiffSide
local function head_pane(worktree, path)
return {
buf = diff.git_show_buf(worktree, "HEAD", path),
name = "git://HEAD/" .. path,
}
end
---@param path string
---@return ow.Git.DiffSide
local function head_empty_pane(path)
return { buf = diff.empty_buf(), name = "git://HEAD/" .. path }
end
---@param worktree string
---@param path string
---@return ow.Git.DiffSide
local function worktree_pane(worktree, path)
return {
buf = diff.load_file_buf(vim.fs.joinpath(worktree, path)),
name = nil,
}
end
---@param path string
---@return ow.Git.DiffSide
local function worktree_empty_pane(path)
return { buf = diff.empty_buf(), name = "git://worktree/" .. path }
end
---@param s ow.Git.StatusState
---@param entry ow.Git.FileEntry
---@return ow.Git.DiffSide
local function index_pane(s, entry)
local in_index = not (
entry.section == "Untracked"
or (entry.section == "Staged" and entry.x == "D")
)
return {
buf = in_index and diff.git_show_buf(s.worktree, "", entry.path, true)
or diff.empty_buf(),
name = "git://index/" .. entry.path,
}
end
---@param s ow.Git.StatusState
---@param entry ow.Git.FileEntry
---@return ow.Git.DiffSide?
local function other_pane(s, entry)
local p = entry.path
local worktree = s.worktree
if entry.section == "Staged" then
if entry.x == "A" then
return head_empty_pane(p)
end
if entry.x == "D" then
return head_pane(worktree, p)
end
-- HEAD holds the pre-rename path
return head_pane(worktree, entry.orig or p)
end
if entry.section == "Unstaged" then
if entry.y == "D" then
return worktree_empty_pane(p)
end
return worktree_pane(worktree, p)
end
if entry.section == "Untracked" then
return worktree_pane(worktree, p)
end
end
---@param s ow.Git.StatusState
---@param entry ow.Git.FileEntry
---@return ow.Git.DiffPair?
local function compute_pair(s, entry)
local other = other_pane(s, entry)
if not other then
return nil
end
return { left = index_pane(s, entry), right = other }
end
---@param win integer
local function reset_diff_win(win)
vim.api.nvim_win_call(win, function()
vim.cmd(
"setlocal winfixwidth< number< relativenumber< signcolumn< wrap< cursorline<"
)
end)
end
---@param sidebar_win integer
---@return integer?
local function find_default_main_win(sidebar_win)
local non_sidebar = {}
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if win ~= sidebar_win then
table.insert(non_sidebar, win)
end
end
if #non_sidebar ~= 1 then
return nil
end
local buf = vim.api.nvim_win_get_buf(non_sidebar[1])
if
vim.api.nvim_buf_get_name(buf) == ""
and vim.bo[buf].buftype == ""
and not vim.bo[buf].modified
and vim.api.nvim_buf_line_count(buf) == 1
and vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] == ""
then
return non_sidebar[1]
end
end
---@param win integer
---@param enabled boolean
local function set_diff(win, enabled)
vim.api.nvim_win_call(win, function()
vim.cmd(enabled and "diffthis" or "diffoff")
end)
if enabled then
vim.wo[win].foldenable = true
vim.wo[win].foldlevel = 0
end
end
---@param s ow.Git.StatusState
---@param sidebar_win integer
---@return integer? left
---@return integer? right
local function adopt_diff_wins(s, sidebar_win)
local left = s.diff_left_win
local right = s.diff_right_win
if left and not vim.api.nvim_win_is_valid(left) then
left = nil
end
if right and not vim.api.nvim_win_is_valid(right) then
right = nil
end
if left and right then
return left, right
end
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if win ~= sidebar_win then
local role = vim.w[win].git_diff_role
if role == "left" and not left then
left = win
elseif role == "right" and not right then
right = win
end
end
end
return left, right
end
---@param entry ow.Git.FileEntry
---@return string
local function entry_key(entry)
return entry.section .. "|" .. entry.path .. "|" .. (entry.orig or "")
end
---@param s ow.Git.StatusState
---@param entry ow.Git.StatusEntry
---@param focus_left boolean
local function show_diff(s, entry, focus_left)
if not entry.path then
return
end
---@cast entry ow.Git.FileEntry
local sidebar_win = sidebar_win_for(s)
if not sidebar_win then
return
end
local left_win, right_win = adopt_diff_wins(s, sidebar_win)
local key = entry_key(entry)
if s.last_shown_key == key and left_win and right_win then
if focus_left then
vim.api.nvim_set_current_win(left_win)
else
vim.api.nvim_set_current_win(sidebar_win)
end
return
end
local pair = compute_pair(s, entry)
if not pair then
return
end
if left_win and not right_win then
vim.api.nvim_set_current_win(left_win)
vim.cmd("rightbelow vertical split")
right_win = vim.api.nvim_get_current_win()
reset_diff_win(right_win)
elseif right_win and not left_win then
vim.api.nvim_set_current_win(right_win)
vim.cmd("leftabove vertical split")
left_win = vim.api.nvim_get_current_win()
reset_diff_win(left_win)
elseif not (left_win or right_win) then
local default_main = find_default_main_win(sidebar_win)
if default_main then
right_win = default_main
reset_diff_win(right_win)
vim.api.nvim_set_current_win(default_main)
vim.cmd("leftabove vertical split")
left_win = vim.api.nvim_get_current_win()
reset_diff_win(left_win)
else
-- No reusable default-empty window. Open the diff pair by
-- splitting from the sidebar. winfixwidth keeps the sidebar at 50
-- when there are other windows to absorb the split; if the
-- sidebar is the only window in the tab, the split has to take
-- from the sidebar itself, so restore the width explicitly.
vim.api.nvim_set_current_win(sidebar_win)
vim.cmd("rightbelow vertical split")
right_win = vim.api.nvim_get_current_win()
reset_diff_win(right_win)
vim.cmd("leftabove vertical split")
left_win = vim.api.nvim_get_current_win()
reset_diff_win(left_win)
vim.api.nvim_win_set_width(sidebar_win, SIDEBAR_WIDTH)
end
local combined = vim.api.nvim_win_get_width(left_win)
+ vim.api.nvim_win_get_width(right_win)
vim.api.nvim_win_set_width(left_win, math.floor(combined / 2))
end
assert(left_win and right_win, "diff windows must be set")
vim.w[left_win].git_diff_role = "left"
vim.w[right_win].git_diff_role = "right"
s.diff_left_win = left_win
s.diff_right_win = right_win
vim.api.nvim_win_set_buf(left_win, pair.left.buf)
vim.api.nvim_win_set_buf(right_win, pair.right.buf)
for _, side in ipairs({ pair.left, pair.right }) do
if side.name then
diff.set_buf_name_and_filetype(side.buf, side.name)
end
end
set_diff(left_win, true)
set_diff(right_win, true)
s.last_shown_key = key
if focus_left then
vim.api.nvim_set_current_win(left_win)
else
vim.api.nvim_set_current_win(sidebar_win)
end
end
---@param focus_left boolean
local function preview_or_open(focus_left)
local s, entry = current_entry(vim.api.nvim_get_current_buf())
if not s or not entry then
return
end
show_diff(s, entry, focus_left)
end
local function action_stage()
local s, entry = current_entry(vim.api.nvim_get_current_buf())
if not s or not entry or not entry.path then
return
end
---@cast entry ow.Git.FileEntry
if entry.section == "Staged" then
return
end
vim.system(
{ "git", "add", "--", entry.path },
{ cwd = s.worktree },
function(obj)
if obj.code ~= 0 then
vim.schedule(function()
log.error("git add failed: %s", vim.trim(obj.stderr or ""))
end)
end
end
)
end
local function action_unstage()
local s, entry = current_entry(vim.api.nvim_get_current_buf())
if not s or not entry or not entry.path then
return
end
---@cast entry ow.Git.FileEntry
if entry.section ~= "Staged" then
return
end
local cmd = { "git", "restore", "--staged", "--" }
if entry.orig then
table.insert(cmd, entry.orig)
end
table.insert(cmd, entry.path)
vim.system(cmd, { cwd = s.worktree }, function(obj)
if obj.code ~= 0 then
vim.schedule(function()
log.error(
"git restore --staged failed: %s",
vim.trim(obj.stderr or "")
)
end)
end
end)
end
local function action_discard()
local s, entry = current_entry(vim.api.nvim_get_current_buf())
if not s or not entry or not entry.path then
return
end
---@cast entry ow.Git.FileEntry
if entry.section == "Staged" then
log.warning("file has staged changes; unstage first with 'u'")
return
end
local prompt, action
if entry.section == "Untracked" then
prompt = string.format("Delete untracked file %s?", entry.path)
action = function()
local ok, err = os.remove(vim.fs.joinpath(s.worktree, entry.path))
if not ok then
log.error("failed to remove %s: %s", entry.path, err or "")
end
refresh(vim.api.nvim_get_current_buf())
end
elseif entry.section == "Unstaged" then
prompt = string.format("Discard changes to %s?", entry.path)
action = function()
vim.system(
{ "git", "checkout", "--", entry.path },
{ cwd = s.worktree },
function(obj)
if obj.code ~= 0 then
vim.schedule(function()
log.error(
"git checkout failed: %s",
vim.trim(obj.stderr or "")
)
end)
end
end
)
end
else
return
end
if vim.fn.confirm(prompt, "&Yes\n&No", 2) == 1 then
action()
end
end
local function action_help()
print(table.concat({
"git status sidebar",
" <Tab> preview diff (keep focus)",
" <CR> open diff (focus left pane)",
" s stage file",
" u unstage file",
" X discard worktree changes (untracked: delete file)",
" g? show this help",
}, "\n"))
end
---@param worktree string
local function open(worktree)
local existing = find_sidebar()
if existing then
vim.api.nvim_set_current_win(existing)
return
end
local gitdir = repo.resolve(worktree)
if not gitdir then
return
end
local previous_win = vim.api.nvim_get_current_win()
vim.cmd("leftabove vertical new")
local win = vim.api.nvim_get_current_win()
local bufnr = vim.api.nvim_get_current_buf()
vim.bo[bufnr].buftype = "nofile"
vim.bo[bufnr].bufhidden = "wipe"
vim.bo[bufnr].swapfile = false
vim.bo[bufnr].filetype = "gitstatus"
vim.bo[bufnr].modifiable = false
vim.wo[win].number = false
vim.wo[win].relativenumber = false
vim.wo[win].wrap = false
vim.wo[win].signcolumn = "no"
vim.wo[win].cursorline = true
vim.wo[win].winfixwidth = true
vim.api.nvim_win_set_width(win, SIDEBAR_WIDTH)
state[bufnr] = {
gitdir = gitdir,
worktree = worktree,
lines = {},
sidebar_win = win,
}
local function k(lhs, rhs, desc)
vim.keymap.set(
"n",
lhs,
rhs,
{ buffer = bufnr, silent = true, desc = desc }
)
end
k("<Tab>", function()
preview_or_open(false)
end, "Preview diff")
k("<CR>", function()
preview_or_open(true)
end, "Open diff")
k("s", action_stage, "Stage file")
k("u", action_unstage, "Unstage file")
k("X", action_discard, "Discard worktree changes")
k("g?", action_help, "Help")
state[bufnr].user_aucmd = vim.api.nvim_create_autocmd("User", {
pattern = "GitRefresh",
group = group,
callback = function(args)
if args.data and args.data.gitdir == gitdir then
refresh(bufnr, args.data.porcelain_stdout)
end
end,
})
vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, {
buffer = bufnr,
group = group,
callback = function()
local s = state[bufnr]
if not s then
return
end
if s.user_aucmd then
pcall(vim.api.nvim_del_autocmd, s.user_aucmd)
end
state[bufnr] = nil
end,
})
vim.api.nvim_set_current_win(previous_win)
refresh(bufnr)
end
function M.toggle()
local sidebar_win = find_sidebar()
if sidebar_win then
vim.api.nvim_win_close(sidebar_win, false)
return
end
local _, worktree = repo.resolve_cwd()
if not worktree then
log.warning("not in a git repository")
return
end
open(worktree)
end
return M