kono-wt 1.1.0

A single-binary CLI + TUI for managing Git worktrees and their GitHub pull requests.
Documentation
//! Ref and branch reads via `gix` (spec ยง4): local branch listing, upstream
//! resolution, ref resolution, and default-branch resolution.

use crate::error::{Error, Result};

/// The configured upstream of a local branch.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Upstream {
    /// Display form, e.g. `origin/feature/x`.
    pub(crate) display: String,
    /// The remote-tracking ref, e.g. `refs/remotes/origin/feature/x`.
    pub(crate) tracking_ref: String,
    /// Whether the tracking ref is gone (configured but no longer present).
    pub(crate) is_gone: bool,
}

/// Lists local branch names (without the `refs/heads/` prefix).
pub(crate) fn local_branches(repo: &gix::Repository) -> Result<Vec<String>> {
    let platform = repo
        .references()
        .map_err(|e| Error::operation(format!("cannot read references: {e}")))?;
    let iter = platform
        .local_branches()
        .map_err(|e| Error::operation(format!("cannot list branches: {e}")))?;
    let mut names = Vec::new();
    for reference in iter {
        let reference =
            reference.map_err(|e| Error::operation(format!("cannot read branch: {e}")))?;
        names.push(reference.name().shorten().to_string());
    }
    names.sort();
    Ok(names)
}

/// Lists remote-tracking branch names (e.g. `origin/main`), skipping the
/// symbolic `<remote>/HEAD` pointers (which alias a real branch, not a fork
/// candidate). Names keep their remote prefix so they read unambiguously.
pub(crate) fn remote_branches(repo: &gix::Repository) -> Result<Vec<String>> {
    let platform = repo
        .references()
        .map_err(|e| Error::operation(format!("cannot read references: {e}")))?;
    let iter = platform
        .remote_branches()
        .map_err(|e| Error::operation(format!("cannot list remote branches: {e}")))?;
    let mut names = Vec::new();
    for reference in iter {
        let reference =
            reference.map_err(|e| Error::operation(format!("cannot read remote branch: {e}")))?;
        let name = reference.name().shorten().to_string();
        // `origin/HEAD` is a symbolic alias for the default branch, not a branch.
        if name.ends_with("/HEAD") {
            continue;
        }
        names.push(name);
    }
    names.sort();
    Ok(names)
}

/// Lists every branch a new worktree can fork from or check out: local branches
/// first (sorted), then remote-tracking branches (sorted). Best-effort โ€” used to
/// populate the TUI create-prompt options dropdown (issue #25).
pub(crate) fn all_branches(repo: &gix::Repository) -> Result<Vec<String>> {
    let mut names = local_branches(repo)?;
    names.extend(remote_branches(repo)?);
    Ok(names)
}

/// The fully-qualified ref of a local branch, i.e. `refs/heads/<branch>`.
///
/// Centralizes the single spelling of this path that the rest of the crate
/// builds whenever it needs a branch's full ref โ€” for `gix` rev-parsing,
/// `git merge-base`, `git branch -f`, and so on.
pub(crate) fn branch_ref(branch: &str) -> String {
    format!("refs/heads/{branch}")
}

/// Validates a user-entered branch name as a legal git branch ref
/// (`git check-ref-format --branch` semantics), returning a human-readable
/// reason on failure. The name is validated as the full ref `refs/heads/<name>`,
/// so single-component lowercase names like `feature` are accepted while
/// illegal forms (`feat..x`, `a b`, `*x`, `.hidden`, `x.lock`, `HEAD`, โ€ฆ) are
/// rejected.
pub(crate) fn validate_branch_name(name: &str) -> std::result::Result<(), String> {
    use gix::bstr::ByteSlice;
    let full = branch_ref(name);
    gix::validate::reference::branch_name(full.as_bytes().as_bstr())
        .map(|_| ())
        .map_err(|e| format!("invalid branch name: {e}"))
}

/// Resolves a revspec to an object id (hex), or `None` if it does not resolve.
pub(crate) fn resolve_hex(repo: &gix::Repository, spec: &str) -> Option<String> {
    repo.rev_parse_single(spec)
        .ok()
        .map(|id| id.detach().to_string())
}

/// Resolves the configured upstream of `branch`, or `None` if none is set.
pub(crate) fn upstream_of(repo: &gix::Repository, branch: &str) -> Option<Upstream> {
    let config = repo.config_snapshot();
    let remote = config.string(format!("branch.{branch}.remote").as_str())?;
    let merge = config.string(format!("branch.{branch}.merge").as_str())?;
    let remote = remote.to_string();
    let merge = merge.to_string();
    let merge_branch = merge.strip_prefix("refs/heads/").unwrap_or(&merge);
    let display = format!("{remote}/{merge_branch}");
    let tracking_ref = format!("refs/remotes/{remote}/{merge_branch}");
    let is_gone = resolve_hex(repo, &tracking_ref).is_none();
    Some(Upstream {
        display,
        tracking_ref,
        is_gone,
    })
}

/// Whether commit-ish `a` is an ancestor of `b` (i.e. `a` is fully merged into
/// `b`), determined offline via `gix`. Returns `false` if either revspec does not
/// resolve or there is no merge base (unrelated histories).
///
/// `a` is an ancestor of `b` exactly when their best merge base is `a` itself;
/// this also yields `true` when `a == b`, matching `git merge-base --is-ancestor`.
pub(crate) fn is_ancestor(repo: &gix::Repository, a: &str, b: &str) -> bool {
    let Some(a_id) = repo.rev_parse_single(a).ok().map(|id| id.detach()) else {
        return false;
    };
    let Some(b_id) = repo.rev_parse_single(b).ok().map(|id| id.detach()) else {
        return false;
    };
    repo.merge_base(a_id, b_id)
        .map(|base| base.detach() == a_id)
        .unwrap_or(false)
}

/// Resolves the repository's default branch (spec ยง7): the `origin/HEAD` target,
/// falling back to the current branch. (`init.defaultBranch` is deliberately not
/// consulted โ€” it governs *new* repositories, not an existing repo's default.)
pub(crate) fn default_branch(repo: &gix::Repository) -> Option<String> {
    origin_head_branch(repo).or_else(|| current_branch(repo))
}

/// The remote-tracking default a new worktree should fork from (issue #70): the
/// `origin/HEAD` target in display form, e.g. `origin/main`. Forking off the
/// remote tip keeps new branches based on the up-to-date default rather than a
/// possibly-stale local copy. `None` when `origin/HEAD` is unset (no remote, or
/// the repo was never cloned) โ€” callers then fall back to their own default.
pub(crate) fn default_base_ref(repo: &gix::Repository) -> Option<String> {
    origin_head_tracking(repo)
}

/// The current branch name, or `None` for a detached HEAD or unborn branch.
pub(crate) fn current_branch(repo: &gix::Repository) -> Option<String> {
    let head = repo.head().ok()?;
    head.referent_name().map(|name| name.shorten().to_string())
}

/// The branch that `refs/remotes/origin/HEAD` points to, if any (the short name,
/// e.g. `main`). Drives default-branch resolution and PR trunk detection.
pub(crate) fn origin_head_branch(repo: &gix::Repository) -> Option<String> {
    // The tracking form is `origin/<branch>`; drop the remote to get the branch
    // (handles slashes in branch names).
    origin_head_tracking(repo)?
        .split_once('/')
        .map(|(_, branch)| branch.to_string())
}

/// The remote-tracking ref `refs/remotes/origin/HEAD` points to, in display form
/// (e.g. `origin/main`), if it is set as a symbolic ref.
fn origin_head_tracking(repo: &gix::Repository) -> Option<String> {
    let reference = repo.find_reference("refs/remotes/origin/HEAD").ok()?;
    match reference.target() {
        gix::refs::TargetRef::Symbolic(name) => {
            // e.g. refs/remotes/origin/main -> origin/main.
            let full = name.as_bstr().to_string();
            full.strip_prefix("refs/remotes/").map(str::to_string)
        }
        gix::refs::TargetRef::Object(_) => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::git::discover::Repo;
    use crate::testutil::TestRepo;

    #[test]
    fn lists_local_branches_sorted() {
        let repo = TestRepo::init();
        repo.git(&["branch", "zeta"]);
        repo.git(&["branch", "alpha"]);
        let r = Repo::discover(repo.root()).unwrap();
        let branches = local_branches(r.gix()).unwrap();
        assert_eq!(branches, vec!["alpha", "main", "zeta"]);
    }

    #[test]
    fn lists_remote_branches_skipping_head() {
        let repo = TestRepo::init();
        let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
        repo.git(&["update-ref", "refs/remotes/origin/main", &head]);
        repo.git(&["update-ref", "refs/remotes/origin/feature/x", &head]);
        // The symbolic origin/HEAD alias must be excluded.
        repo.git(&[
            "symbolic-ref",
            "refs/remotes/origin/HEAD",
            "refs/remotes/origin/main",
        ]);
        let r = Repo::discover(repo.root()).unwrap();
        let remotes = remote_branches(r.gix()).unwrap();
        assert_eq!(remotes, vec!["origin/feature/x", "origin/main"]);
    }

    #[test]
    fn all_branches_lists_locals_then_remotes() {
        let repo = TestRepo::init();
        repo.git(&["branch", "zeta"]);
        let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
        repo.git(&["update-ref", "refs/remotes/origin/main", &head]);
        let r = Repo::discover(repo.root()).unwrap();
        let all = all_branches(r.gix()).unwrap();
        // Local branches (sorted) first, then remote-tracking branches (sorted).
        assert_eq!(all, vec!["main", "zeta", "origin/main"]);
    }

    #[test]
    fn validates_branch_names() {
        // Legal branch names, including slashes, dashes, underscores, digits.
        for ok in ["feature", "feature/x", "fix-bug_123", "release/v1.2"] {
            assert!(
                validate_branch_name(ok).is_ok(),
                "expected {ok:?} to be valid"
            );
        }
        // Illegal forms are rejected with a reason.
        for bad in [
            "feat..x", "a b", "*x", ".hidden", "feature/", "x.lock", "HEAD", "",
        ] {
            let err = validate_branch_name(bad).unwrap_err();
            assert!(
                err.starts_with("invalid branch name:"),
                "expected {bad:?} to be rejected, got {err:?}"
            );
        }
    }

    #[test]
    fn branch_ref_prefixes_refs_heads() {
        assert_eq!(branch_ref("main"), "refs/heads/main");
        // Slashes in the branch name are preserved, not escaped.
        assert_eq!(branch_ref("feature/login"), "refs/heads/feature/login");
    }

    #[test]
    fn resolves_refs() {
        let repo = TestRepo::init();
        let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
        let r = Repo::discover(repo.root()).unwrap();
        assert_eq!(resolve_hex(r.gix(), "HEAD").as_deref(), Some(head.as_str()));
        assert_eq!(
            resolve_hex(r.gix(), "refs/heads/main").as_deref(),
            Some(head.as_str())
        );
        assert!(resolve_hex(r.gix(), "refs/heads/nope").is_none());
    }

    #[test]
    fn upstream_present_absent_and_gone() {
        let repo = TestRepo::init();
        let r = Repo::discover(repo.root()).unwrap();
        // No upstream configured.
        assert!(upstream_of(r.gix(), "main").is_none());

        // Configure an upstream with a present tracking ref.
        let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
        repo.git(&["update-ref", "refs/remotes/origin/main", &head]);
        repo.git(&["config", "branch.main.remote", "origin"]);
        repo.git(&["config", "branch.main.merge", "refs/heads/main"]);
        let r = Repo::discover(repo.root()).unwrap();
        let up = upstream_of(r.gix(), "main").unwrap();
        assert_eq!(up.display, "origin/main");
        assert_eq!(up.tracking_ref, "refs/remotes/origin/main");
        assert!(!up.is_gone);

        // Delete the tracking ref -> gone.
        repo.git(&["update-ref", "-d", "refs/remotes/origin/main"]);
        let r = Repo::discover(repo.root()).unwrap();
        let up = upstream_of(r.gix(), "main").unwrap();
        assert!(up.is_gone);
    }

    #[test]
    fn default_branch_prefers_origin_head() {
        let repo = TestRepo::init();
        let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
        repo.git(&["update-ref", "refs/remotes/origin/main", &head]);
        repo.git(&[
            "symbolic-ref",
            "refs/remotes/origin/HEAD",
            "refs/remotes/origin/main",
        ]);
        let r = Repo::discover(repo.root()).unwrap();
        assert_eq!(default_branch(r.gix()).as_deref(), Some("main"));
    }

    #[test]
    fn default_branch_falls_back_to_current() {
        let repo = TestRepo::init();
        let r = Repo::discover(repo.root()).unwrap();
        assert_eq!(default_branch(r.gix()).as_deref(), Some("main"));
        assert_eq!(current_branch(r.gix()).as_deref(), Some("main"));
    }

    #[test]
    fn default_base_ref_is_origin_head_tracking_form() {
        // The default base is the remote-tracking ref (origin/main), so a new
        // worktree forks off the up-to-date remote tip (issue #70).
        let repo = TestRepo::init();
        let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
        repo.git(&["update-ref", "refs/remotes/origin/main", &head]);
        repo.git(&[
            "symbolic-ref",
            "refs/remotes/origin/HEAD",
            "refs/remotes/origin/main",
        ]);
        let r = Repo::discover(repo.root()).unwrap();
        assert_eq!(default_base_ref(r.gix()).as_deref(), Some("origin/main"));
    }

    #[test]
    fn default_base_ref_none_without_origin_head() {
        // No origin/HEAD: there is no confident remote default, so the caller
        // falls back to its own resolution rather than guessing.
        let repo = TestRepo::init();
        let r = Repo::discover(repo.root()).unwrap();
        assert_eq!(default_base_ref(r.gix()), None);
    }

    #[test]
    fn is_ancestor_true_when_merged_false_when_divergent() {
        let repo = TestRepo::init();
        // `topic` branches off main with no extra commits: an ancestor of main.
        repo.git(&["branch", "topic"]);
        let r = Repo::discover(repo.root()).unwrap();
        assert!(is_ancestor(r.gix(), "refs/heads/topic", "refs/heads/main"));
        // A ref is trivially an ancestor of itself (matches `--is-ancestor`).
        assert!(is_ancestor(r.gix(), "refs/heads/main", "refs/heads/main"));
        // Add a commit on `topic` so it diverges: no longer an ancestor of main.
        repo.git(&["checkout", "topic"]);
        repo.write("t.txt", "1\n");
        repo.commit_all("topic work");
        let r = Repo::discover(repo.root()).unwrap();
        assert!(!is_ancestor(r.gix(), "refs/heads/topic", "refs/heads/main"));
        // ...but main is still an ancestor of topic.
        assert!(is_ancestor(r.gix(), "refs/heads/main", "refs/heads/topic"));
    }

    #[test]
    fn is_ancestor_false_for_missing_ref() {
        let repo = TestRepo::init();
        let r = Repo::discover(repo.root()).unwrap();
        assert!(!is_ancestor(r.gix(), "refs/heads/nope", "refs/heads/main"));
    }

    #[test]
    fn is_ancestor_false_for_unrelated_histories() {
        // Two roots with no common ancestor: `git merge-base --is-ancestor`
        // exits non-zero (no merge base), so this must be `false`, not a panic.
        let repo = TestRepo::init();
        repo.git(&["checkout", "--orphan", "unrelated"]);
        repo.write("o.txt", "x\n");
        repo.commit_all("orphan root");
        let r = Repo::discover(repo.root()).unwrap();
        assert!(!is_ancestor(
            r.gix(),
            "refs/heads/main",
            "refs/heads/unrelated"
        ));
    }
}