Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

nushell/modules/git.nu

Raw
# Git worktree helpers.
# Layout:
#   <root>/.git          bare repository
#   <root>/<branch-slug> worktrees

const WT_MAIN_BRANCH = "wt.main-branch"

const DOT_GIT = ".git"

const DEFAULT_MAIN_BRANCH = "main"
const HEAD = "HEAD"
const REF_HEADS = "refs/heads/"
const DIRTY_REFUSE = "refuse"
const DIRTY_COPY = "copy"
const DIRTY_MODES = ["refuse" "copy"]
const STATUS_PORCELAIN = ["status" "--porcelain=v1"]
const STAGED_PATCH = "staged.patch"
const UNSTAGED_PATCH = "unstaged.patch"

const LINE_WORKTREE = "worktree "
const LINE_HEAD = "HEAD "
const LINE_BRANCH = "branch "
const LINE_DETACHED = "detached"
const LINE_BARE = "bare"
const LINE_LOCKED = "locked"
const LINE_PRUNABLE = "prunable"

# Run git and return completion record.
def git-complete [args: list<string>, cwd?: path] {
    if $cwd == null {
        ^git ...$args | complete
    } else {
        ^git -C $cwd ...$args | complete
    }
}

# Format a failed git invocation.
def git-failure [args: list<string>, stderr: string] {
    let msg = ($stderr | str trim)
    if ($msg | is-empty) {
        $"git failed: git ($args | str join ' ')"
    } else {
        $msg
    }
}

def choose-recovery [message: string, choices: list<record>, non_interactive: string] {
    if not $nu.is-interactive { error make {msg: $non_interactive} }

    print -e ""
    print -e $message
    let selected = ($choices | input list --display label "choose")
    if $selected == null or $selected.id == "cancel" { error make {msg: "cancelled"} }
    $selected.id
}

def remove-needs-force [message: string] {
    let modified = ($message | str contains "contains modified or untracked files")
    let submodules = ($message | str contains "working trees containing submodules")
    let dirty = ($message | str contains "is dirty")
    let not_clean = ($message | str contains "not clean")
    $modified or $submodules or $dirty or $not_clean
}

# Run git and return trimmed stdout; fail with stderr as Nushell error.
def git-output [args: list<string>, cwd?: path] {
    let res = if $cwd == null { git-complete $args } else { git-complete $args $cwd }

    if $res.exit_code != 0 {
        error make {msg: (git-failure $args $res.stderr)}
    }

    $res.stdout | str trim
}

# Compatibility wrappers used by the rest of this module.
def gitx [args: list<string>, cwd?: path] {
    if $cwd == null { git-output $args } else { git-output $args $cwd }
}

def gitc [args: list<string>, cwd?: path] {
    if $cwd == null { git-complete $args } else { git-complete $args $cwd }
}

def git-dir-args [git_dir: path, args: list<string>] {
    ["--git-dir" $git_dir] | append $args
}

def git-dir-output [git_dir: path, args: list<string>] {
    git-output (git-dir-args $git_dir $args)
}

def git-dir-complete [git_dir: path, args: list<string>] {
    git-complete (git-dir-args $git_dir $args)
}

def git-config [git_dir: path, key: string] {
    let res = (git-dir-complete $git_dir ["config" "--get" $key])
    if $res.exit_code == 0 { $res.stdout | str trim } else { null }
}

def git-config-values [git_dir: path, key: string] {
    let res = (git-dir-complete $git_dir ["config" "--get-all" $key])
    if $res.exit_code == 0 { $res.stdout | lines } else { [] }
}

def git-config-bool [git_dir: path, key: string] {
    let res = (git-dir-complete $git_dir ["config" "--bool" "--get" $key])
    if $res.exit_code == 0 { $res.stdout | str trim } else { null }
}

def git-config-set [git_dir: path, key: string, value: string] {
    git-dir-output $git_dir ["config" $key $value] | ignore
}

def git-config-unset [git_dir: path, key: string] {
    git-dir-complete $git_dir ["config" "--unset" $key] | ignore
}

def ensure-upstream [git_dir: path, branch: string] {
    if (git-config $git_dir "remote.origin.url") == null { return }

    if (git-config $git_dir "remote.origin.fetch") == null {
        git-config-set $git_dir "remote.origin.fetch" "+refs/heads/*:refs/remotes/origin/*"
    }

    if (git-config $git_dir $"branch.($branch).remote") == null {
        git-config-set $git_dir $"branch.($branch).remote" "origin"
        git-config-set $git_dir $"branch.($branch).merge" $"refs/heads/($branch)"
    }

    if (git-dir-complete $git_dir ["show-ref" "--verify" "--quiet" $"refs/heads/($branch)"]).exit_code == 0 {
        git-dir-output $git_dir ["update-ref" $"refs/remotes/origin/($branch)" $"refs/heads/($branch)"] | ignore
    }
}

# Branch name -> path-safe suffix.
# Slashes stay slashes, so `features/make-it-good` keeps its folder hierarchy.
# Every segment is normalized on its own: characters that win32 rejects in file
# names become `-`, and DOS device names get a trailing `-` so the path stays
# usable on both win32 and linux.
def branch-slug [branch: string] {
    let illegal = '[<>:"|?*\\\s-]+'
    let reserved = ["NUL" "CON" "PRN" "AUX" "COM1" "COM2" "COM3" "COM4" "COM5" "COM6" "COM7" "COM8" "COM9" "LPT1" "LPT2" "LPT3" "LPT4" "LPT5" "LPT6" "LPT7" "LPT8" "LPT9"]

    $branch
    | split row "/"
    | each {|segment|
        let cleaned = ($segment | str replace --all --regex $illegal '-' | str trim --char '-')
        if ($cleaned | is-empty) {
            null
        } else if (($cleaned | str uppercase) in $reserved) {
            $"($cleaned)-"
        } else {
            $cleaned
        }
    }
    | where {|segment| $segment != null }
    | str join "/"
}

# URL/path -> repo name.
def repo-name-from [source: string] {
    $source | path basename | str replace --regex '\.git$' ''
}

# Current layout record. Fails outside co/wt layout.
def layout [] {
    let git_dir = (git-output ["rev-parse" "--path-format=absolute" "--git-common-dir"])

    if (git-config-bool $git_dir "core.bare") != "true" {
        error make {msg: "not inside co/wt layout: git common dir is not bare"}
    }

    let root = ($git_dir | path dirname)
    let repo = ($root | path basename)
    let main = (git-config $git_dir $WT_MAIN_BRANCH | default $DEFAULT_MAIN_BRANCH)

    {root: $root, git_dir: $git_dir, repo: $repo, main: $main}
}

# <root>/<branch-slug> as a native path.
# The slug may contain `/`, so join it segment-wise instead of embedding a
# foreign separator into a win32 path.
def branch-path [root: path, branch: string] {
    [$root] | append (branch-slug $branch | split row "/") | path join
}

# Top-level entry that a branch's worktree occupies inside the layout root.
def branch-root-entry [branch: string] {
    branch-slug $branch | split row "/" | first
}

# Computed conventional worktree path for branch.
def wt-path [branch: string] {
    branch-path (layout).root $branch
}

def empty-worktree-record [] {
    {path: null, head: null, branch: null, detached: false, bare: false, locked: false, lock_reason: null, prunable: false}
}

def strip-prefix [line: string, prefix: string] {
    $line | str replace $prefix ""
}

def parse-lock-line [line: string] {
    let reason = (strip-prefix $line $LINE_LOCKED | str trim)
    if ($reason | is-empty) { null } else { $reason }
}

def parse-worktree-line [rec: record, line: string] {
    if ($line | str starts-with $LINE_WORKTREE) {
        return ($rec | upsert path (strip-prefix $line $LINE_WORKTREE))
    }
    if ($line | str starts-with $LINE_HEAD) {
        return ($rec | upsert head (strip-prefix $line $LINE_HEAD))
    }
    if ($line | str starts-with $LINE_BRANCH) {
        let branch = (strip-prefix $line $LINE_BRANCH | str replace $REF_HEADS "")
        return ($rec | upsert branch $branch)
    }
    if $line == $LINE_DETACHED {
        return ($rec | upsert detached true)
    }
    if $line == $LINE_BARE {
        return ($rec | upsert bare true)
    }
    if ($line | str starts-with $LINE_LOCKED) {
        return ($rec | upsert locked true | upsert lock_reason (parse-lock-line $line))
    }
    if ($line | str starts-with $LINE_PRUNABLE) {
        return ($rec | upsert prunable true)
    }

    $rec
}

def parse-worktree-block [block: string] {
    $block | lines | reduce --fold (empty-worktree-record) {|line, rec| parse-worktree-line $rec $line }
}

# Parse `git worktree list --porcelain` into records.
def parse-worktree-list [text: string] {
    $text
    | split row "\n\n"
    | where {|block| not (($block | str trim) | is-empty) }
    | each {|block| parse-worktree-block $block }
}

# Worktree table, no dirty check.
def worktrees-basic [] {
    let l = (layout)
    parse-worktree-list (git-dir-output $l.git_dir ["worktree" "list" "--porcelain"])
}

# Find actual worktree path by branch; fallback to convention path.
def path-for-branch [branch: string] {
    let found = (worktrees-basic | where branch == $branch | first)
    if ($found == null) { wt-path $branch } else { $found.path }
}

def branch-exists [git_dir: path, branch: string] {
    (git-dir-complete $git_dir ["show-ref" "--verify" "--quiet" $"($REF_HEADS)($branch)"]).exit_code == 0
}

def add-candidate-display [branch: string, source: string, base: string] {
    $"($branch)  ($source)  ($base)"
}

def local-add-candidates [git_dir: path] {
    git-dir-output $git_dir ["for-each-ref" "--format=%(refname:short)" "refs/heads"]
    | lines
    | where {|branch| not (($branch | str trim) | is-empty) }
    | each {|branch| {branch: $branch, base: $branch, source: "local", display: (add-candidate-display $branch "local" $branch)} }
}

def remote-add-candidates [git_dir: path] {
    git-dir-output $git_dir ["for-each-ref" "--format=%(refname:short)" "refs/remotes"]
    | lines
    | where {|ref| not (($ref | str trim) | is-empty) }
    | where {|ref| not ($ref | str ends-with "/HEAD") }
    | each {|ref|
        let branch = ($ref | split row "/" | skip 1 | str join "/")
        {branch: $branch, base: $ref, source: "remote", display: (add-candidate-display $branch "remote" $ref)}
    }
}

def git-remotes [git_dir: path] {
    git-dir-output $git_dir ["remote"]
    | lines
    | where {|name| not (($name | str trim) | is-empty) }
    | each {|name| {name: $name, url: (git-dir-output $git_dir ["remote" "get-url" $name])} }
}

def gh-pr-add-candidates [remotes: list<record>] {
    if (which gh | is-empty) { return [] }
    if (^gh auth status | complete).exit_code != 0 { return [] }

    $remotes
    | each {|remote|
        let res = (^gh pr list --repo $remote.url --json number,headRefName,title --limit 100 | complete)
        if $res.exit_code != 0 or (($res.stdout | str trim) | is-empty) {
            []
        } else {
            $res.stdout
            | from json
            | each {|pr|
                let branch = $pr.headRefName
                let base = $"pull/($pr.number)/head"
                {branch: $branch, base: $base, source: "gh-pr", remote: $remote.name, number: $pr.number, display: (add-candidate-display $branch $"gh ($remote.name) #($pr.number)" $pr.title)}
            }
        }
    }
    | flatten
}

def parse-fj-pr-line [line: string] {
    let trimmed = ($line | str trim)
    let first = ($trimmed | split row " " | first | str replace "#" "")
    if not ($first =~ '^\d+$') { return null }

    let title = (
        $trimmed
        | str replace --regex '^#?\d+\s+' ''
        | str replace --regex '\s+\(by .+\)$' ''
        | str trim
    )

    let number = ($first | into int)
    {branch: $"pr-($number)", base: $"pull/($number)/head", source: "fj-pr", number: $number, title: $title, display: (add-candidate-display $"pr-($number)" $"fj #($number)" $title)}
}

def fj-pr-add-candidates [remotes: list<record>] {
    if (which fj | is-empty) { return [] }
    if (^fj auth list | complete).exit_code != 0 { return [] }

    $remotes
    | each {|remote|
        let res = (^fj --style minimal pr -R $remote.name search --state open | complete)
        if $res.exit_code != 0 or (($res.stdout | str trim) | is-empty) {
            []
        } else {
            $res.stdout
            | lines
            | each {|line|
                let parsed = (parse-fj-pr-line $line)
                if $parsed == null { null } else { $parsed | upsert remote $remote.name | upsert display (add-candidate-display $parsed.branch $"fj ($remote.name) #($parsed.number)" $parsed.title) }
            }
            | where {|candidate| $candidate != null }
        }
    }
    | flatten
}

def dedupe-add-candidates [] {
    $in | sort-by branch | uniq-by branch
}

# Candidate branches for `wt add`.
# PR lookups spawn `gh`/`fj` with network round trips, so they stay opt-in.
def raw-add-candidates [include_prs: bool] {
    let l = (layout)
    let occupied = (worktrees-basic | where branch != null | get --optional branch)

    let candidates = if $include_prs {
        let remotes = (git-remotes $l.git_dir)
        gh-pr-add-candidates $remotes | append (fj-pr-add-candidates $remotes)
    } else {
        local-add-candidates $l.git_dir | append (remote-add-candidates $l.git_dir)
    }

    $candidates | where {|candidate|
        (not ($candidate.branch in $occupied)) and (not ((wt-path $candidate.branch) | path exists))
    }
}

def available-add-candidates [include_prs: bool] {
    raw-add-candidates $include_prs | dedupe-add-candidates
}

def add-candidate-matches [candidate: record, query: string] {
    let branch = ($candidate.branch | str contains --ignore-case $query)
    let base = ($candidate.base | str contains --ignore-case $query)
    let display = ($candidate.display | str contains --ignore-case $query)
    $branch or $base or $display
}

def pick-add-candidate [query: any, include_prs: bool] {
    let candidates = (available-add-candidates $include_prs)
    let matches = if $query == null {
        $candidates
    } else {
        $candidates | where {|candidate| add-candidate-matches $candidate $query }
    }

    if ($matches | is-empty) {
        if $query != null {
            let action = (choose-recovery
                $"No local branch, remote branch, or PR matched `($query)`."
                [
                    {id: "create", label: $"Create new branch `($query)` from HEAD"}
                    {id: "cancel", label: "Cancel"}
                ]
                $"no branch/remote/PR matching `($query)`. Non-interactive: create explicitly with `git wt new ($query)` or pass `git wt add ($query) --base <base>`."
            )
            if $action == "create" { return {branch: $query, base: $HEAD, source: "new", display: $"($query)  new  HEAD"} }
        }
        error make {msg: "no branches or PRs available; use `wt new` to create a branch"}
    }

    if ($matches | length) == 1 { return ($matches | first) }

    let selected = ($matches | input list --fuzzy --display display "branch")
    if $selected == null { error make {msg: "no branch selected"} }
    $selected
}

def ensure-fetch-refspec [git_dir: path, remote: string, refspec: string] {
    let key = $"remote.($remote).fetch"
    if not ($refspec in (git-config-values $git_dir $key)) {
        git-dir-output $git_dir ["config" "--add" $key $refspec] | ignore
    }
}

def split-remote-branch [git_dir: path, ref: string] {
    let parts = ($ref | split row "/")
    if ($parts | length) < 2 { return null }

    let remote = ($parts | first)
    let branch = ($parts | skip 1 | str join "/")
    if (git-dir-complete $git_dir ["show-ref" "--verify" "--quiet" $"refs/remotes/($remote)/($branch)"]).exit_code != 0 { return null }
    {remote: $remote, branch: $branch}
}

def configure-branch-upstream [git_dir: path, branch: string, remote: string, merge_ref: string] {
    git-config-set $git_dir $"branch.($branch).remote" $remote
    git-config-set $git_dir $"branch.($branch).merge" $merge_ref
}

def configure-upstream-from-base [git_dir: path, branch: string, base: string] {
    let remote_branch = (split-remote-branch $git_dir $base)
    if $remote_branch != null {
        configure-branch-upstream $git_dir $branch $remote_branch.remote $"refs/heads/($remote_branch.branch)"
    }
}

def configure-upstream-matching-remote [git_dir: path, branch: string] {
    for remote in (git-remotes $git_dir) {
        if (git-dir-complete $git_dir ["show-ref" "--verify" "--quiet" $"refs/remotes/($remote.name)/($branch)"]).exit_code == 0 {
            configure-branch-upstream $git_dir $branch $remote.name $"refs/heads/($branch)"
            return
        }
    }
}

def ensure-branch-points-at [git_dir: path, branch: string, target_ref: string, label: string] {
    if not (branch-exists $git_dir $branch) { return }

    let local = (git-dir-output $git_dir ["rev-parse" $branch])
    let target = (git-dir-output $git_dir ["rev-parse" $target_ref])
    if $local == $target { return }

    let action = (choose-recovery
        $"Local branch `($branch)` already exists but does not point at selected ($label)."
        [
            {id: "reset", label: $"Reset `($branch)` to ($label)"}
            {id: "cancel", label: "Cancel"}
        ]
        $"local branch `($branch)` differs from selected ($label). Non-interactive: delete/reset it first, then rerun `git wt add ($branch)`."
    )
    if $action == "reset" { git-dir-output $git_dir ["branch" "-f" $branch $target_ref] | ignore }
}

def create-picked-worktree [candidate: record, submodules: bool] {
    let l = (layout)
    if $candidate.source == "remote" {
        ensure-branch-points-at $l.git_dir $candidate.branch $candidate.base $"remote branch ($candidate.base)"
        let path = (create-worktree $candidate.branch $candidate.base $submodules)
        configure-upstream-from-base $l.git_dir $candidate.branch $candidate.base
        return $path
    }

    if $candidate.source in ["gh-pr" "fj-pr"] {
        let tracking_ref = $"refs/remotes/($candidate.remote)/pull/($candidate.number)"
        let refspec = $"+refs/pull/($candidate.number)/head:($tracking_ref)"
        ensure-fetch-refspec $l.git_dir $candidate.remote $refspec
        git-dir-output $l.git_dir ["fetch" $candidate.remote $"refs/pull/($candidate.number)/head:($tracking_ref)"] | ignore
        ensure-branch-points-at $l.git_dir $candidate.branch $tracking_ref $"PR #($candidate.number) from ($candidate.remote)"
        let path = (create-worktree $candidate.branch $tracking_ref $submodules)
        configure-branch-upstream $l.git_dir $candidate.branch $candidate.remote $"refs/pull/($candidate.number)/head"
        return $path
    }

    let path = (create-worktree $candidate.branch $candidate.base $submodules)
    if $candidate.source == "local" { configure-upstream-matching-remote $l.git_dir $candidate.branch }
    $path
}

# Init + checkout submodules inside a freshly added worktree.
# No-op when disabled or the worktree has no .gitmodules. Best-effort:
# failure (e.g. no network/auth) warns but leaves the worktree intact.
def init-submodules [path: path, enabled: bool] {
    if not $enabled { return }
    if not (([$path ".gitmodules"] | path join) | path exists) { return }

    let res = (git-complete ["submodule" "update" "--init" "--recursive"] $path)
    if $res.exit_code != 0 {
        print -e $"warning: submodule init failed in ($path); run `git -C ($path) submodule update --init --recursive` to retry"
        let detail = ($res.stderr | str trim)
        if not ($detail | is-empty) { print -e $detail }
    }
}

def add-worktree-needs-fetch [message: string] {
    let invalid = ($message | str contains "invalid reference")
    let unknown = ($message | str contains "unknown revision")
    let not_commit = ($message | str contains "not a commit")
    let no_remote = ($message | str contains "couldn't find remote ref")
    $invalid or $unknown or $not_commit or $no_remote
}

def add-worktree [git_dir: path, path: path, branch: string, base: string, submodules: bool] {
    let args = if (branch-exists $git_dir $branch) {
        ["worktree" "add" $path $branch]
    } else {
        ["worktree" "add" "-b" $branch $path $base]
    }

    let res = (git-dir-complete $git_dir $args)
    if $res.exit_code != 0 {
        let msg = (git-failure $args $res.stderr)
        if not (add-worktree-needs-fetch $msg) { error make {msg: $msg} }

        let action = (choose-recovery
            $"Git could not create this worktree.\n\n($msg)\n\nOften the base branch/ref is stale or has not been fetched yet."
            [
                {id: "fetch", label: "Fetch all remotes, then retry"}
                {id: "cancel", label: "Cancel"}
            ]
            $"($msg)\nNon-interactive: run `git fetch --all --prune`, then retry the `git wt` command."
        )
        if $action == "fetch" {
            git-dir-output $git_dir ["fetch" "--all" "--prune"] | ignore
            git-dir-output $git_dir $args | ignore
        }
    }

    init-submodules $path $submodules
}

# Create branch + worktree, or add existing branch.
def create-worktree [branch: string, base: string, submodules: bool] {
    let l = (layout)
    let existing = (worktrees-basic | where branch == $branch | first)
    if $existing != null {
        let action = (choose-recovery
            $"Branch `($branch)` already has a worktree:\n($existing.path)"
            [
                {id: "use", label: "Use existing worktree"}
                {id: "cancel", label: "Cancel"}
            ]
            $"branch `($branch)` already has worktree ($existing.path). Non-interactive: use `git wt go ($branch)`."
        )
        if $action == "use" { return $existing.path }
    }

    let path = (wt-path $branch)

    if ($path | path exists) {
        let action = (choose-recovery
            $"Target path already exists: ($path)\nGit cannot create a worktree on top of it."
            [
                {id: "use", label: "Use existing path; do not create anything"}
                {id: "remove", label: "Delete existing path, then create worktree"}
                {id: "cancel", label: "Cancel"}
            ]
            $"target exists: ($path)\nNon-interactive: remove it first (`rm -rf ($path)`) or use it manually."
        )
        if $action == "use" { return $path }
        rm -rf $path
    }

    # Create parent directories for nested worktree paths (e.g. features/make-it-good).
    let parent = ($path | path dirname)
    if ($parent != $path) {
        mkdir $parent | ignore
    }

    add-worktree $l.git_dir $path $branch $base $submodules
    configure-upstream-from-base $l.git_dir $branch $base
    $path
}

# Current branch, refusing detached HEAD.
def current-branch [] {
    let branch = (git-output ["rev-parse" "--abbrev-ref" $HEAD])
    if $branch == $HEAD { error make {msg: "detached HEAD is not supported"} }
    $branch
}

def current-repo-top [] {
    try { git-output ["rev-parse" "--show-toplevel"] | path expand } catch { "" }
}

def existing-main-or-root [layout: record] {
    let main_path = (path-for-branch $layout.main)
    if ($main_path | path exists) { $main_path } else { $layout.root }
}

def --env cd-away-from-target [layout: record, target: path, branch: string] {
    if $target != (current-repo-top) { return }

    if $branch == $layout.main {
        cd $layout.root
    } else {
        cd (existing-main-or-root $layout)
    }
}

def remove-worktree-args [target: path, force: bool] {
    if $force { ["worktree" "remove" "--force" $target] } else { ["worktree" "remove" $target] }
}

def remove-worktree-git [git_dir: path, target: path, force: bool, command_hint: string] {
    let res = (git-dir-complete $git_dir (remove-worktree-args $target $force))
    if $res.exit_code == 0 { return }

    let msg = (git-failure (remove-worktree-args $target $force) $res.stderr)
    if $force or not (remove-needs-force $msg) { error make {msg: $msg} }

    let action = (choose-recovery
        $"Git refused to remove this worktree.\n\n($msg)\n\nUsually this means dirty files or submodules are present. Force removal deletes that worktree directory."
        [
            {id: "force", label: "Force remove worktree"}
            {id: "cancel", label: "Cancel"}
        ]
        $"($msg)\nNon-interactive: rerun with `($command_hint) --force`."
    )
    if $action == "force" { git-dir-output $git_dir (remove-worktree-args $target true) | ignore }
}

def delete-branch [git_dir: path, branch: string, force: bool, command_hint: string] {
    let flag = if $force { "-D" } else { "-d" }
    let res = (git-dir-complete $git_dir ["branch" $flag $branch])
    if $res.exit_code == 0 { return }

    let msg = (git-failure ["branch" $flag $branch] $res.stderr)
    if $force { error make {msg: $msg} }

    let action = (choose-recovery
        $"Git refused to delete branch `($branch)`.\n\n($msg)\n\nUsually this means commits are not merged upstream."
        [
            {id: "force", label: $"Force delete branch `($branch)`"}
            {id: "keep", label: "Keep branch"}
            {id: "cancel", label: "Cancel"}
        ]
        $"($msg)\nNon-interactive: rerun with `($command_hint) --force` to force-delete, or use `git wt rm ($branch)` to keep the branch."
    )
    if $action == "force" { git-dir-output $git_dir ["branch" "-D" $branch] | ignore }
}

# Remove worktree, maybe branch, and cd away if removing current worktree.
def --env remove-worktree [branch: string, delete_branch: bool, force: bool, command_hint: string] {
    let l = (layout)
    let target = (path-for-branch $branch | path expand)

    cd-away-from-target $l $target $branch
    remove-worktree-git $l.git_dir $target $force $command_hint

    if $delete_branch {
        delete-branch $l.git_dir $branch $force $command_hint
    }

    $target
}



def choose-fresh-branch [git_dir: path, branch: any] {
    if (($branch == null) or ($branch | is-empty)) {
        git-dir-output $git_dir ["symbolic-ref" "--short" $HEAD]
    } else {
        $branch
    }
}

def selected-branch [branch: any] {
    if (($branch == null) or ($branch | is-empty)) { current-branch } else { $branch }
}

# Create fresh layout from remote/local repo URL/path.
def --env co-fresh [source: string, name?: string, branch?: string, dir?: path, submodules: bool = true] {
    let repo = if ($name == null) { repo-name-from $source } else { $name }
    let parent = if ($dir == null) { "." } else { $dir }
    let root = ([$parent $repo] | path join | path expand)
    let git_dir = ([$root $DOT_GIT] | path join)

    if ($root | path exists) {
        let action = (choose-recovery
            $"Clone target already exists: ($root)"
            [
                {id: "remove", label: "Delete target directory, then clone"}
                {id: "cancel", label: "Cancel"}
            ]
            $"target exists: ($root). Non-interactive: choose another name/dir or remove it first."
        )
        if $action == "remove" { rm -rf $root }
    }

    mkdir $root
    git-output ["clone" "--bare" $source $git_dir] | ignore

    let chosen_branch = (choose-fresh-branch $git_dir $branch)
    git-config-set $git_dir $WT_MAIN_BRANCH $chosen_branch
    ensure-upstream $git_dir $chosen_branch
    git-dir-complete $git_dir ["fetch" "--all" "--prune"] | ignore

    let worktree = (branch-path $root $chosen_branch)
    git-dir-output $git_dir ["worktree" "add" $worktree $chosen_branch] | ignore
    init-submodules $worktree $submodules
    cd $worktree

    {root: $root, worktree: $worktree, branch: $chosen_branch}
}

def status-porcelain [cwd: path] {
    git-output $STATUS_PORCELAIN $cwd
}

def stash-count [cwd: path] {
    let res = (git-complete ["stash" "list"] $cwd)
    if $res.exit_code == 0 { $res.stdout | lines | length } else { 0 }
}

def assert-dirty-mode [dirty: string] {
    if not ($dirty in $DIRTY_MODES) {
        error make {msg: $"--dirty must be one of: ($DIRTY_MODES | str join ', ')"}
    }
}

def migration-plan [dirty_mode: string, branch: any] {
    assert-dirty-mode $dirty_mode

    let src = (git-output ["rev-parse" "--show-toplevel"] | path expand)
    let chosen_branch = (selected-branch $branch)
    let target = (branch-path $src $chosen_branch)
    let status_before = (status-porcelain $src)
    let is_dirty = (not ($status_before | is-empty))

    {
        mode: "current-checkout"
        source: $src
        target_root: $src
        target_git_dir: ([$src $DOT_GIT] | path join)
        target_worktree: $target
        repo: ($src | path basename)
        branch: $chosen_branch
        dirty: $is_dirty
        dirty_mode: $dirty_mode
        stashes: (stash-count $src)
        destructive: true
        status_before: $status_before
    }
}

def public-migration [plan: record] {
    $plan | reject status_before
}

def resolve-migration-dirty [plan: record] {
    if not ($plan.dirty and $plan.dirty_mode == $DIRTY_REFUSE) { return $plan }

    let action = (choose-recovery
        "Working tree has uncommitted changes. Migration can copy them into the new worktree, or you can cancel and commit/stash manually."
        [
            {id: "copy", label: "Copy dirty state into migrated worktree"}
            {id: "cancel", label: "Cancel"}
        ]
        "working tree has changes. Non-interactive: rerun with `git wt migrate --dirty copy`, or commit/stash first."
    )
    if $action == "copy" { return ($plan | upsert dirty_mode $DIRTY_COPY) }
    $plan
}

def assert-migration-ready [plan: record] {
    if $plan.dirty and $plan.dirty_mode == $DIRTY_REFUSE {
        error make {msg: "working tree has changes; rerun with `--dirty copy` or commit/stash first"}
    }
}

def patch-paths [tmp: path] {
    {staged: ([$tmp $STAGED_PATCH] | path join), unstaged: ([$tmp $UNSTAGED_PATCH] | path join)}
}

def write-dirty-patches [source: path, tmp: path] {
    let patches = (patch-paths $tmp)
    ^git -C $source diff --binary --cached | save --force $patches.staged
    ^git -C $source diff --binary | save --force $patches.unstaged
    $patches
}

def patch-has-data [patch: path] {
    not ((open --raw $patch) | is-empty)
}

def apply-patch [target: path, args: list<string>, patch: path] {
    if (patch-has-data $patch) {
        git-output (["apply"] | append $args | append $patch) $target | ignore
    }
}

def apply-dirty-patches [target: path, patches: record] {
    apply-patch $target ["--index"] $patches.staged
    apply-patch $target [] $patches.unstaged
}

def copy-untracked [source: path, target: path, --exclude: list<string> = []] {
    mkdir $target
    let skip = ([$DOT_GIT] | append $exclude)
    for entry in (ls -a $source | get name) {
        if (($entry | path basename) in $skip) { continue }
        cp -r --no-dereference --no-clobber $entry $target
    }
}

def copy-dirty-state [plan: record, patches: record] {
    apply-dirty-patches $plan.target_worktree $patches
    copy-untracked $plan.source $plan.target_worktree --exclude [($plan.target_worktree | path basename)]

    let status_after = (status-porcelain $plan.target_worktree)
    if $plan.status_before != $status_after {
        error make {msg: $"dirty-copy verification failed; target left for inspection at ($plan.target_root)"}
    }
}

# Copy stash refs from old .git to new bare .git.
# `git clone --bare` does not transport refs/stash.
def copy-git-config [old_git: path, new_git: path] {
    let src = ([$old_git "config"] | path join)
    let dst = ([$new_git "config"] | path join)
    if ($src | path exists) { cp --force $src $dst }
}

def preserve-stash-refs [old_git: path, new_git: path] {
    for sub in ["refs/stash" "logs/refs/stash"] {
        let src = ([$old_git $sub] | path join)
        let dst = ([$new_git $sub] | path join)
        if ($src | path exists) {
            mkdir ($dst | path dirname)
            cp -r --no-dereference --no-clobber $src $dst
        }
    }
}

# Remove top-level entries from `root` except those in `keep`.
def clear-stale-files [root: path, keep: list<string>] {
    let keep_set = ($keep | append [".", ".."])
    for entry in (ls -a $root) {
        if (($entry.name | path basename) not-in $keep_set) {
            rm -rf $entry.name
        }
    }
}

# Convert current checkout in place to bare+worktree layout.
# Original tracked files and untracked files are removed; dirty state is
# restored from patches, untracked files are copied, stashes are preserved.
def --env co-current [branch?: string, plan: bool = false, dirty: string = $DIRTY_REFUSE, submodules: bool = true] {
    let migration = (resolve-migration-dirty (migration-plan $dirty $branch))
    if $plan { return (public-migration $migration) }

    assert-migration-ready $migration

    let src = $migration.source
    let old_git = ([$src $DOT_GIT] | path join)
    let old_git_backup = ([$src ".git.wt-backup"] | path join)
    let new_git = ([$src ".git.wt-new"] | path join)
    let tmp = (mktemp -d)

    if ($old_git_backup | path exists) { error make {msg: $"backup exists: ($old_git_backup)"} }
    if ($new_git | path exists) { error make {msg: $"temporary git dir exists: ($new_git)"} }

    try {
        let patches = if $migration.dirty { write-dirty-patches $src $tmp } else { null }

        # Keep .git backup on same filesystem: cross-device mv copies sockets and can ENXIO.
        git-output ["clone" "--bare" $src $new_git] | ignore
        mv $old_git $old_git_backup
        mv $new_git $old_git

        copy-git-config $old_git_backup $migration.target_git_dir
        git-config-set $migration.target_git_dir "core.bare" "true"
        git-config-unset $migration.target_git_dir "core.worktree"
        preserve-stash-refs $old_git_backup $migration.target_git_dir
        git-config-set $migration.target_git_dir $WT_MAIN_BRANCH $migration.branch
        ensure-upstream $migration.target_git_dir $migration.branch
        git-dir-output $migration.target_git_dir ["worktree" "add" $migration.target_worktree $migration.branch] | ignore

        if $migration.dirty {
            copy-dirty-state $migration $patches
        }

        clear-stale-files $src [$DOT_GIT (branch-root-entry $migration.branch) ".git.wt-backup"]
        init-submodules $migration.target_worktree $submodules

        rm -rf $old_git_backup
        rm -rf $tmp
    } catch {|e|
        if ($new_git | path exists) { rm -rf $new_git }
        if ($old_git_backup | path exists) {
            rm -rf $old_git
            mv $old_git_backup $old_git
        }
        rm -rf $tmp
        error make {msg: ($e | get msg)}
    }

    cd $migration.target_worktree
    public-migration $migration
}

# Bootstrap a new bare+worktree layout from a URL/path.
export def --env "wt clone" [
    source: string             # Git URL/path to clone into bare+worktree layout
    name?: string              # Root directory name; default derives from source basename
    --branch (-b): string = "" # Initial branch to check out; default remote HEAD
    --dir (-d): path = "."     # Parent directory for the new layout root
    --no-submodules            # Skip submodule init in the initial worktree
] {
    co-fresh $source $name $branch $dir (not $no_submodules)
}

# Migrate the current checkout in place to a bare+worktree layout.
# Destructive: original tracked files are removed; dirty state, untracked
# files, and stashes are preserved.
export def --env "wt migrate" [
    --branch (-b): string = ""      # Branch to make the first worktree; default current branch
    --plan                          # Print migration plan only; do not modify files
    --dirty: string = $DIRTY_REFUSE # Dirty handling: `refuse` or `copy`; prompt if dirty and omitted
    --no-submodules                 # Skip submodule init in the migrated worktree
] {
    co-current $branch $plan $dirty (not $no_submodules)
}

# Summary.
export def wt [] { wt ls }

export def "wt root" [] { (layout).root }
export def "wt repo" [] { (layout).repo }
export def "wt path" [
    branch: string # Branch name to map to its conventional worktree path
] { wt-path $branch }

def worktree-dirty [row: record] {
    if (($row.path | path exists) and (not $row.bare) and ($row.branch != null)) {
        not ((status-porcelain $row.path) | is-empty)
    } else {
        false
    }
}

export def "wt ls" [] {
    let cwd_top = (current-repo-top)

    worktrees-basic | where bare == false | each {|row|
        let path = ($row.path | path expand)
        $row
        | upsert current ($path == $cwd_top)
        | upsert dirty (worktree-dirty $row)
    }
}

export def --env "wt new" [
    branch: string                 # New or existing local branch to create a worktree for
    --base (-b): string = $HEAD    # Base ref for a new branch; ignored when branch already exists
    --switch (-s)                  # cd into the created or existing worktree
    --no-submodules                # Skip `git submodule update --init --recursive`
] {
    let path = (create-worktree $branch $base (not $no_submodules))
    if $switch { cd $path }
    $path
}

export def --env "wt go" [
    branch: string                 # Branch/worktree to cd into
    --create (-c)                  # Create missing worktree without prompting
    --base (-b): string = $HEAD    # Base ref when creating a missing branch/worktree
    --no-submodules                # With create: skip submodule init
] {
    let path = (path-for-branch $branch)
    if not ($path | path exists) {
        if not $create {
            let action = (choose-recovery
                $"No worktree exists for branch `($branch)`."
                [
                    {id: "create", label: $"Create worktree for `($branch)`"}
                    {id: "cancel", label: "Cancel"}
                ]
                $"no worktree for branch `($branch)`. Non-interactive: rerun with `git wt go ($branch) --create`."
            )
            if $action != "create" { error make {msg: "cancelled"} }
        }
        create-worktree $branch $base (not $no_submodules) | ignore
    }
    cd $path
    $path
}

export def --env "wt rm" [
    name?: string                  # Branch/worktree to remove; default current branch
    --branch (-b)                  # Also delete the local branch after removing the worktree
    --force (-f)                   # Skip prompts and force removal/deletion where Git allows
] {
    let b = if ($name == null) { current-branch } else { $name }
    remove-worktree $b $branch $force $"git wt rm ($b)"
}

export def --env "wt done" [
    branch?: string                # Branch/worktree to finish; default current branch
    --force (-f)                   # Skip prompts and force worktree removal + branch deletion
] {
    let b = if ($branch == null) { current-branch } else { $branch }
    remove-worktree $b true $force $"git wt done ($b)"
}

export def "wt prune" [] {
    let l = (layout)
    let res = (git-dir-complete $l.git_dir ["worktree" "prune"])
    if $res.exit_code == 0 { return }
    error make {msg: (git-failure ["worktree" "prune"] $res.stderr)}
}

export def "wt repair" [] {
    let l = (layout)
    let res = (git-dir-complete $l.git_dir ["worktree" "repair"])
    if $res.exit_code == 0 { return }
    error make {msg: (git-failure ["worktree" "repair"] $res.stderr)}
}

# Thin native aliases for muscle memory / git parity.
export def --env "wt add" [
    branch?: string                # Filter local/remote/PR picker; omitted opens full picker
    --base (-b): string = $HEAD    # Explicitly create a new branch from this base instead of picking
    --switch (-s)                  # cd into the created worktree
    --no-submodules                # Skip submodule init in the created worktree
    --pr                           # Only show PR candidates (gh/fj); local/remote branches excluded
] {
    if $branch != null and $base != $HEAD {
        return (wt new $branch --base $base --switch=$switch --no-submodules=$no_submodules)
    }

    let selected = (pick-add-candidate $branch $pr)
    let path = (create-picked-worktree $selected (not $no_submodules))
    if $switch { cd $path }
    $path
}

export def "wt list" [] { wt ls }

export def "wt lock" [
    branch?: string                # Worktree/branch to lock; default current branch
    reason?: string                # Optional lock reason stored by Git
] {
    let b = if ($branch == null) { current-branch } else { $branch }
    let path = (path-for-branch $b)
    let l = (layout)

    if not ($path | path exists) {
        let action = (choose-recovery
            $"No worktree path exists for `($b)`: ($path)"
            [
                {id: "create", label: "Create worktree, then lock it"}
                {id: "cancel", label: "Cancel"}
            ]
            $"no worktree path for `($b)`. Non-interactive: run `git wt go ($b) --create`, then `git wt lock ($b)`."
        )
        if $action == "create" { create-worktree $b $HEAD true | ignore }
    }

    let args = if ($reason == null) { ["worktree" "lock" $path] } else { ["worktree" "lock" "--reason" $reason $path] }
    let res = (git-dir-complete $l.git_dir $args)
    if $res.exit_code == 0 { return }

    let msg = (git-failure $args $res.stderr)
    if ($msg | str contains "already locked") {
        let action = (choose-recovery
            $"Worktree `($b)` is already locked.\n\n($msg)"
            [
                {id: "relock", label: "Unlock, then lock again"}
                {id: "cancel", label: "Cancel"}
            ]
            $"($msg)\nNon-interactive: run `git wt unlock ($b)`, then retry `git wt lock ($b)`."
        )
        if $action == "relock" {
            git-dir-output $l.git_dir ["worktree" "unlock" $path] | ignore
            git-dir-output $l.git_dir $args | ignore
            return
        }
    }

    error make {msg: $msg}
}

export def "wt unlock" [
    branch?: string                # Worktree/branch to unlock; default current branch
] {
    let b = if ($branch == null) { current-branch } else { $branch }
    let path = (path-for-branch $b)
    let l = (layout)
    let res = (git-dir-complete $l.git_dir ["worktree" "unlock" $path])
    if $res.exit_code == 0 { return }

    let msg = (git-failure ["worktree" "unlock" $path] $res.stderr)
    if ($msg | str contains "not locked") {
        if $nu.is-interactive { print -e $"Worktree `($b)` is not locked. Nothing to do." }
        return
    }
    error make {msg: $msg}
}

export def "wt move" [
    branch: string                 # Worktree/branch to move
    dest: path                     # New worktree path
] {
    let l = (layout)
    let source = (path-for-branch $branch)

    if ($dest | path exists) {
        let action = (choose-recovery
            $"Destination already exists: ($dest)"
            [
                {id: "remove", label: "Delete destination, then move"}
                {id: "cancel", label: "Cancel"}
            ]
            $"destination exists: ($dest). Non-interactive: remove it first, then rerun `git wt move ($branch) ($dest)`."
        )
        if $action == "remove" { rm -rf $dest }
    }

    let res = (git-dir-complete $l.git_dir ["worktree" "move" $source $dest])
    if $res.exit_code == 0 { return $dest }

    let msg = (git-failure ["worktree" "move" $source $dest] $res.stderr)
    if ($msg | str contains "working trees containing submodules") {
        let action = (choose-recovery
            $"Git refuses to move worktrees containing submodules.\n\n($msg)\n\nSafe workaround: filesystem move, then `git worktree repair`."
            [
                {id: "repair", label: "Move directory, then repair worktree metadata"}
                {id: "cancel", label: "Cancel"}
            ]
            $"($msg)\nNon-interactive: run `mv ($source) ($dest)` then `git wt repair`."
        )
        if $action == "repair" {
            mv $source $dest
            git-dir-output $l.git_dir ["worktree" "repair" $dest] | ignore
            return $dest
        }
    }

    error make {msg: $msg}
}

export def --env "wt remove" [
    name?: string                  # Branch/worktree to remove; default current branch
    --branch (-b)                  # Also delete the local branch after removing the worktree
    --force (-f)                   # Skip prompts and force removal/deletion where Git allows
] {
    wt rm $name --branch=$branch --force=$force
}