kono-wt 1.8.0

A single-binary CLI + TUI for managing Git worktrees and their GitHub pull requests.
Documentation
//! Pure parsers for `git` porcelain output (spec ยง4 sanctioned subprocess
//! reads). Kept separate from the I/O so they are unit-testable on fixed input.

use std::path::PathBuf;

/// A worktree as reported by `git worktree list --porcelain`, before any
/// filesystem checks or enrichment.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RawWorktree {
    /// Absolute path of the worktree.
    pub(crate) path: PathBuf,
    /// Checked-out commit (hex OID), or `None` for the bare entry.
    pub(crate) head: Option<String>,
    /// Branch name (without the `refs/heads/` prefix), or `None` if
    /// detached/bare.
    pub(crate) branch: Option<String>,
    /// Whether this is the bare repository entry.
    pub(crate) is_bare: bool,
    /// Whether the worktree has a detached HEAD.
    pub(crate) is_detached: bool,
    /// Whether the worktree is locked.
    pub(crate) is_locked: bool,
    /// The reason given when the worktree was locked, if any.
    pub(crate) lock_reason: Option<String>,
    /// Whether Git considers the worktree prunable.
    pub(crate) is_prunable: bool,
    /// Whether this is the main (first) worktree.
    pub(crate) is_main: bool,
    /// Whether the worktree's directory is missing on disk. Left `false` by the
    /// parser; filled in by enumeration, which can touch the filesystem.
    pub(crate) is_missing: bool,
}

impl RawWorktree {
    fn new(path: PathBuf) -> Self {
        RawWorktree {
            path,
            head: None,
            branch: None,
            is_bare: false,
            is_detached: false,
            is_locked: false,
            lock_reason: None,
            is_prunable: false,
            is_main: false,
            is_missing: false,
        }
    }
}

/// Parses `git worktree list --porcelain` output. The first record is marked as
/// the main worktree. Missing-directory detection is applied separately (it
/// requires filesystem access).
pub(crate) fn parse_worktree_list(porcelain: &str) -> Vec<RawWorktree> {
    let mut result: Vec<RawWorktree> = Vec::new();
    let mut current: Option<RawWorktree> = None;
    for line in porcelain.lines() {
        if line.is_empty() {
            if let Some(wt) = current.take() {
                result.push(wt);
            }
            continue;
        }
        let (key, rest) = match line.split_once(' ') {
            Some((k, r)) => (k, Some(r)),
            None => (line, None),
        };
        match key {
            "worktree" => {
                if let Some(wt) = current.take() {
                    result.push(wt);
                }
                current = Some(RawWorktree::new(PathBuf::from(rest.unwrap_or_default())));
            }
            "HEAD" => {
                if let Some(wt) = current.as_mut() {
                    wt.head = rest.map(str::to_string);
                }
            }
            "branch" => {
                if let Some(wt) = current.as_mut() {
                    wt.branch = rest.map(strip_branch_ref);
                }
            }
            "bare" => {
                if let Some(wt) = current.as_mut() {
                    wt.is_bare = true;
                }
            }
            "detached" => {
                if let Some(wt) = current.as_mut() {
                    wt.is_detached = true;
                }
            }
            "locked" => {
                if let Some(wt) = current.as_mut() {
                    wt.is_locked = true;
                    wt.lock_reason = rest.map(str::to_string).filter(|r| !r.is_empty());
                }
            }
            "prunable" => {
                if let Some(wt) = current.as_mut() {
                    wt.is_prunable = true;
                }
            }
            _ => {}
        }
    }
    if let Some(wt) = current.take() {
        result.push(wt);
    }
    if let Some(first) = result.first_mut() {
        first.is_main = true;
    }
    result
}

/// Strips the `refs/heads/` prefix from a branch ref.
fn strip_branch_ref(reference: &str) -> String {
    reference
        .strip_prefix("refs/heads/")
        .unwrap_or(reference)
        .to_string()
}

/// One submodule as reported by `git submodule status`. The leading marker is
/// `' '` (in sync), `'-'` (not initialized), `'+'` (checked-out commit differs
/// from the index), or `'U'` (merge conflicts).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SubmoduleStatus {
    /// The leading status marker character.
    pub(crate) state: char,
    /// The submodule's path, relative to the superproject.
    pub(crate) path: String,
}

impl SubmoduleStatus {
    /// Whether the submodule is not yet initialized (marker `'-'`).
    pub(crate) fn is_uninitialized(&self) -> bool {
        self.state == '-'
    }
}

/// Parses `git submodule status` output. Each line is
/// `<marker><sha> <path>[ (<describe>)]`; the marker is the first character and
/// is *not* separated from the SHA by a space. Lines that do not parse are
/// skipped.
pub(crate) fn parse_submodule_status(output: &str) -> Vec<SubmoduleStatus> {
    let mut result = Vec::new();
    for line in output.lines() {
        let mut chars = line.chars();
        let Some(state) = chars.next() else {
            continue;
        };
        // After the marker char comes `<sha> <path>[ (<describe>)]`.
        let rest = chars.as_str();
        let Some((_sha, after_sha)) = rest.split_once(' ') else {
            continue;
        };
        // Drop a trailing ` (<describe>)` annotation, keeping paths intact.
        let path = match after_sha.rfind(" (") {
            Some(i) => &after_sha[..i],
            None => after_sha,
        }
        .trim();
        if path.is_empty() {
            continue;
        }
        result.push(SubmoduleStatus {
            state,
            path: path.to_string(),
        });
    }
    result
}

/// One submodule as declared in a `.gitmodules` file: git's internal *name* for
/// it (which is what names its gitdir under `.git/modules/`), the working-tree
/// *path* it is checked out at, and the *url* it is cloned from.
///
/// Name and path are distinct and must not be conflated: a submodule can be
/// renamed in `.gitmodules` while keeping its path, and `.git/modules/<name>`
/// follows the name.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Submodule {
    /// Git's name for the submodule (the `submodule.<name>.*` config section).
    pub(crate) name: String,
    /// Working-tree path relative to the superproject.
    pub(crate) path: String,
}

/// Parses `git config -f .gitmodules -z --get-regexp ^submodule\..*` output.
///
/// The `-z` format is a NUL-separated list of entries, each `<key>\n<value>`;
/// a key with no value has no newline. Only `.path` is load-bearing here โ€” the
/// URL is deliberately not collected, because seeding must never read a URL out
/// of `.gitmodules` and act on it.
///
/// Submodule names may themselves contain dots (`submodule.a.b.path`), so the
/// name is everything between the `submodule.` prefix and the *final* key
/// segment โ€” splitting on the first dot would truncate such names.
pub(crate) fn parse_gitmodules(output: &str) -> Vec<Submodule> {
    let mut subs: Vec<Submodule> = Vec::new();
    for entry in output.split('\0') {
        if entry.is_empty() {
            continue;
        }
        // A valueless key carries no information for us.
        let Some((key, value)) = entry.split_once('\n') else {
            continue;
        };
        let Some(rest) = key.strip_prefix("submodule.") else {
            continue;
        };
        let Some((name, field)) = rest.rsplit_once('.') else {
            continue;
        };
        if field != "path" || name.is_empty() || value.is_empty() {
            continue;
        }
        // Preserve declaration order; a duplicate key means the last one wins,
        // matching git's own config semantics.
        match subs.iter_mut().find(|s| s.name == name) {
            Some(existing) => existing.path = value.to_string(),
            None => subs.push(Submodule {
                name: name.to_string(),
                path: value.to_string(),
            }),
        }
    }
    subs
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_main_and_linked() {
        let input = "worktree /repo\nHEAD aaa111\nbranch refs/heads/main\n\
            \n\
            worktree /repo.worktrees/feat\nHEAD bbb222\nbranch refs/heads/feature/x\n\n";
        let wts = parse_worktree_list(input);
        assert_eq!(wts.len(), 2);
        assert_eq!(wts[0].path, PathBuf::from("/repo"));
        assert_eq!(wts[0].branch.as_deref(), Some("main"));
        assert_eq!(wts[0].head.as_deref(), Some("aaa111"));
        assert!(wts[0].is_main);
        assert_eq!(wts[1].path, PathBuf::from("/repo.worktrees/feat"));
        assert_eq!(wts[1].branch.as_deref(), Some("feature/x"));
        assert!(!wts[1].is_main);
    }

    #[test]
    fn parses_detached_and_bare_and_locked_and_prunable() {
        let input = "worktree /bare\nbare\n\
            \n\
            worktree /d\nHEAD ccc333\ndetached\n\
            \n\
            worktree /l\nHEAD ddd\nbranch refs/heads/x\nlocked being used\n\
            \n\
            worktree /p\nHEAD eee\nbranch refs/heads/y\nprunable gitdir gone\n\n";
        let wts = parse_worktree_list(input);
        assert_eq!(wts.len(), 4);
        assert!(wts[0].is_bare && wts[0].is_main);
        assert!(wts[0].branch.is_none() && wts[0].head.is_none());
        assert!(wts[1].is_detached);
        assert!(wts[1].branch.is_none());
        assert!(wts[2].is_locked);
        assert_eq!(wts[2].lock_reason.as_deref(), Some("being used"));
        assert_eq!(wts[2].branch.as_deref(), Some("x"));
        assert!(!wts[1].is_locked && wts[1].lock_reason.is_none());
        assert!(wts[3].is_prunable);
    }

    #[test]
    fn handles_trailing_record_without_blank_line() {
        let input = "worktree /only\nHEAD f00\nbranch refs/heads/main";
        let wts = parse_worktree_list(input);
        assert_eq!(wts.len(), 1);
        assert_eq!(wts[0].branch.as_deref(), Some("main"));
    }

    #[test]
    fn handles_paths_with_spaces() {
        let input = "worktree /my repo/wt\nHEAD a1\nbranch refs/heads/main\n";
        let wts = parse_worktree_list(input);
        assert_eq!(wts[0].path, PathBuf::from("/my repo/wt"));
    }

    #[test]
    fn empty_input_yields_no_worktrees() {
        assert!(parse_worktree_list("").is_empty());
    }

    #[test]
    fn parses_submodule_status_markers() {
        let input = "-aaa111 libs/uninit\n cccddd libs/ok (heads/main)\n\
            +bbb222 vendor/drift (v1.2-3-gabcdef)\nUeee444 vendor/conflict\n";
        let subs = parse_submodule_status(input);
        assert_eq!(subs.len(), 4);
        assert_eq!(subs[0].state, '-');
        assert_eq!(subs[0].path, "libs/uninit");
        assert!(subs[0].is_uninitialized());
        assert_eq!(subs[1].state, ' ');
        assert_eq!(subs[1].path, "libs/ok");
        assert!(!subs[1].is_uninitialized());
        assert_eq!(subs[2].state, '+');
        assert_eq!(subs[2].path, "vendor/drift");
        assert_eq!(subs[3].state, 'U');
        assert_eq!(subs[3].path, "vendor/conflict");
    }

    #[test]
    fn submodule_status_keeps_paths_with_spaces() {
        let subs = parse_submodule_status("-deadbeef my libs/sub\n");
        assert_eq!(subs.len(), 1);
        assert_eq!(subs[0].path, "my libs/sub");
    }

    #[test]
    fn submodule_status_skips_unparseable_lines() {
        // Empty input, a marker with no SHA/path, and a marker+SHA with no path.
        assert!(parse_submodule_status("").is_empty());
        assert!(parse_submodule_status("-\n").is_empty());
        assert!(parse_submodule_status("-onlysha\n").is_empty());
    }

    #[test]
    fn gitmodules_pairs_names_with_paths_in_declaration_order() {
        let out = "submodule.vendor/A.path\nvendor/A\0submodule.vendor/A.url\n../a\0\
                   submodule.vendor/B.path\nvendor/B\0submodule.vendor/B.url\n../b\0";
        let subs = parse_gitmodules(out);
        assert_eq!(subs.len(), 2);
        assert_eq!(subs[0].name, "vendor/A");
        assert_eq!(subs[0].path, "vendor/A");
        assert_eq!(subs[1].name, "vendor/B");
    }

    #[test]
    fn gitmodules_keeps_dots_inside_submodule_names() {
        // `rsplit_once` is what makes this work: splitting on the first dot
        // would yield the name "libs", losing the rest.
        let subs = parse_gitmodules("submodule.libs.core.v2.path\nlibs/core\0");
        assert_eq!(subs.len(), 1);
        assert_eq!(subs[0].name, "libs.core.v2");
        assert_eq!(subs[0].path, "libs/core");
    }

    #[test]
    fn gitmodules_skips_entries_without_a_usable_path() {
        // A url-only entry, a valueless key, a foreign section, an empty path,
        // and a key with no field segment.
        let out = "submodule.only.url\n../x\0submodule.bare.path\0core.bare\nfalse\0\
                   submodule.blank.path\n\0submodule\nx\0";
        assert!(parse_gitmodules(out).is_empty());
    }

    #[test]
    fn gitmodules_last_duplicate_path_wins() {
        let subs = parse_gitmodules("submodule.a.path\nold\0submodule.a.path\nnew\0");
        assert_eq!(subs.len(), 1);
        assert_eq!(subs[0].path, "new");
    }
}