agent-berth 2.2.0

Monitor and resume coding agents
Documentation
use super::*;

fn write(path: &Path, text: &str) {
    std::fs::create_dir_all(path.parent().unwrap()).unwrap();
    std::fs::write(path, text).unwrap();
}

#[test]
fn reads_branch_from_git_directory() {
    let root = tempfile::tempdir().unwrap();
    write(&root.path().join(".git/HEAD"), "ref: refs/heads/main\n");
    assert_eq!(branch(root.path()).as_deref(), Some("main"));
}

#[test]
fn finds_repository_from_subdirectory() {
    let root = tempfile::tempdir().unwrap();
    write(
        &root.path().join(".git/HEAD"),
        "ref: refs/heads/feature/x\n",
    );
    let nested = root.path().join("a/b");
    std::fs::create_dir_all(&nested).unwrap();
    assert_eq!(branch(&nested).as_deref(), Some("feature/x"));
}

#[test]
fn reads_branch_from_worktree_gitdir_file() {
    let root = tempfile::tempdir().unwrap();
    let worktree = root.path().join("worktree");
    let git_dir = root.path().join("repo/.git/worktrees/worktree");
    write(&git_dir.join("HEAD"), "ref: refs/heads/topic\n");
    write(
        &worktree.join(".git"),
        &format!("gitdir: {}\n", git_dir.display()),
    );
    assert_eq!(branch(&worktree).as_deref(), Some("topic"));
}

#[test]
fn detached_head_uses_short_hash() {
    let root = tempfile::tempdir().unwrap();
    write(
        &root.path().join(".git/HEAD"),
        "0123456789abcdef0123456789abcdef01234567\n",
    );
    assert_eq!(branch(root.path()).as_deref(), Some("01234567"));
}

#[test]
fn missing_repository_has_no_branch() {
    let root = tempfile::tempdir().unwrap();
    assert_eq!(branch(root.path()), None);
}

#[test]
fn parses_github_remote_urls() {
    assert_eq!(
        parse_github_url("git@github.com:owner/repo.git").as_deref(),
        Some("owner/repo")
    );
    assert_eq!(
        parse_github_url("https://github.com/owner/repo.git").as_deref(),
        Some("owner/repo")
    );
    assert_eq!(
        parse_github_url("ssh://git@github.com/owner/repo").as_deref(),
        Some("owner/repo")
    );
    assert_eq!(parse_github_url("https://gitlab.com/owner/repo"), None);
    assert_eq!(parse_github_url("https://github.com/owner"), None);
}

#[test]
fn parses_porcelain_status() {
    let text = "# branch.head main\n# branch.ab +2 -1\n# stash 1\n1 M. N... 100644 100644 100644 abc abc a.rs\n1 .M N... 100644 100644 100644 abc abc b.rs\n? new.rs\nu UU N... 100644 100644 100644 100644 abc abc abc c.rs\n";
    assert_eq!(
        parse_porcelain(text),
        RepoStatus {
            conflicted: true,
            stashed: true,
            modified: true,
            staged: true,
            untracked: true,
            tracking: Some((2, 1)),
            ..RepoStatus::default()
        }
    );
}

#[test]
fn porcelain_distinguishes_change_kinds() {
    for (record, expected) in [
        (
            "1 M. N... 100644 100644 100644 abc abc staged.rs",
            RepoStatus {
                staged: true,
                ..RepoStatus::default()
            },
        ),
        (
            "1 .M N... 100644 100644 100644 abc abc modified.rs",
            RepoStatus {
                modified: true,
                ..RepoStatus::default()
            },
        ),
        (
            "1 MM N... 100644 100644 100644 abc abc both.rs",
            RepoStatus {
                staged: true,
                modified: true,
                ..RepoStatus::default()
            },
        ),
        (
            "1 .D N... 100644 100644 000000 abc abc deleted.rs",
            RepoStatus {
                deleted: true,
                ..RepoStatus::default()
            },
        ),
        (
            "2 R. N... 100644 100644 100644 abc abc R100 new.rs\told.rs",
            RepoStatus {
                renamed: true,
                staged: true,
                ..RepoStatus::default()
            },
        ),
        (
            "u UU N... 100644 100644 100644 100644 abc abc abc conflict.rs",
            RepoStatus {
                conflicted: true,
                ..RepoStatus::default()
            },
        ),
        (
            "? untracked.rs",
            RepoStatus {
                untracked: true,
                ..RepoStatus::default()
            },
        ),
        (
            "1 .M S..U 160000 160000 160000 abc abc submodule",
            RepoStatus {
                modified: true,
                ..RepoStatus::default()
            },
        ),
    ] {
        assert_eq!(parse_porcelain(record), expected, "{record}");
    }
}

#[test]
fn porcelain_tracks_divergence_without_treating_headers_as_changes() {
    for (header, tracking) in [
        ("# branch.ab +0 -0", Some((0, 0))),
        ("# branch.ab +2 -0", Some((2, 0))),
        ("# branch.ab +0 -1", Some((0, 1))),
        ("# branch.ab +2 -1", Some((2, 1))),
        ("# branch.head main\n! ignored.rs\n# stash 0", None),
        ("# branch.ab invalid", None),
    ] {
        assert_eq!(
            parse_porcelain(header),
            RepoStatus {
                tracking,
                ..RepoStatus::default()
            }
        );
    }
}

#[test]
fn repo_info_reads_real_repository() {
    let root = tempfile::tempdir().unwrap();
    let Ok(init) = std::process::Command::new("git")
        .args(["init", "-b", "main"])
        .current_dir(root.path())
        .output()
    else {
        return;
    };
    if !init.status.success() {
        return;
    }
    std::fs::write(root.path().join("new.rs"), "fn main() {}\n").unwrap();
    std::process::Command::new("git")
        .args(["remote", "add", "origin", "git@github.com:owner/repo.git"])
        .current_dir(root.path())
        .output()
        .unwrap();
    let info = repo_info(root.path()).unwrap();
    assert_eq!(info.branch, "main");
    assert_eq!(
        info.status,
        RepoStatus {
            untracked: true,
            ..RepoStatus::default()
        }
    );
    assert_eq!(info.github.as_deref(), Some("owner/repo"));
}

#[test]
fn repo_info_is_none_outside_repository() {
    let root = tempfile::tempdir().unwrap();
    assert!(repo_info(root.path()).is_none());
}