add ref viewer

This commit is contained in:
2026-08-12 10:40:12 +07:00
parent 5f38c08331
commit 54781e2ad9
6 changed files with 751 additions and 211 deletions
+218 -16
View File
@@ -309,6 +309,104 @@ pub fn worktree_all_commits(workdir: &Path) -> Result<Vec<FileCommit>> {
all_commits(&gix::open(workdir)?)
}
/// Short names of local branches (`refs/heads/*`), sorted alphabetically.
pub fn worktree_branches(workdir: &Path) -> Result<Vec<String>> {
let repo = gix::open(workdir)?;
let mut names = Vec::new();
for reference in repo.references()?.local_branches()? {
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned());
}
names.sort();
Ok(names)
}
/// Short names of tags (`refs/tags/*`), sorted alphabetically.
pub fn worktree_tags(workdir: &Path) -> Result<Vec<String>> {
let repo = gix::open(workdir)?;
let mut names = Vec::new();
for reference in repo.references()?.tags()? {
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned());
}
names.sort();
Ok(names)
}
/// Short name of the branch HEAD points to, or `None` when detached (e.g.
/// after checking out a tag or a commit directly).
pub fn current_branch(repo: &gix::Repository) -> Result<Option<String>> {
let head = repo.head()?;
let Some(name) = head.referent_name() else {
return Ok(None);
};
Ok(Some(String::from_utf8_lossy(name.shorten()).into_owned()))
}
/// Everything the browser needs to refresh after a branch or tag switch.
pub struct WorktreeSnapshot {
/// Relative paths of all worktree entries, directories first.
pub entries: Vec<PathBuf>,
/// README path relative to the worktree, if any.
pub readme_path: Option<PathBuf>,
/// Contents of the README, if any.
pub readme: Option<Vec<u8>>,
/// Branch HEAD points to (`None` when detached, e.g. on a tag).
pub current_branch: Option<String>,
}
/// Snapshot the worktree after a branch/tag switch: entries, README and the
/// branch HEAD points to, opening the repository once.
pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
let repo = gix::open(workdir)?;
let readme_path = find_readme(&repo)?;
let readme = match &readme_path {
Some(path) => worktree_read(&repo, path)?,
None => None,
};
Ok(WorktreeSnapshot {
entries: worktree_entries(&repo)?,
readme_path,
readme,
current_branch: current_branch(&repo)?,
})
}
/// Switch the checked-out ref and update the worktree to match, like
/// `git checkout --force`. Local modifications are discarded since these
/// clones are read-only browser copies.
fn checkout(workdir: &Path, args: &[&str]) -> Result<()> {
let output = Command::new("git")
.arg("checkout")
.arg("--force")
.args(args)
.current_dir(workdir)
.output()
.context("failed to spawn `git checkout`")?;
if !output.status.success() {
bail!(
"git checkout {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
/// Check out the local branch `name`; HEAD stays attached to it.
pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> {
// The short name (not `refs/heads/<name>`) keeps HEAD attached; the
// full ref name would be treated as a commit-ish and detach it.
checkout(workdir, &[name])
}
/// Check out the tag `name`; HEAD becomes detached at the tagged commit,
/// which [`current_branch`] reports as `None`.
pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> {
// `--detach` pins the full tag ref so HEAD always ends up detached.
checkout(workdir, &["--detach", &format!("refs/tags/{name}")])
}
fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
@@ -433,22 +531,23 @@ mod tests {
/// Stage everything and create a commit with the git CLI (like
/// [`apply_patch`], the crate already shells out to the CLI).
fn commit_all(repo: &gix::Repository, message: &str) {
let dir = repo.workdir().expect("workdir");
let run = |args: &[&str]| {
let status = Command::new("git")
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "Test Author")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test Author")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.env("GIT_EDITOR", "true")
.args(args)
.status()
.expect("spawn git");
assert!(status.success(), "git {args:?} failed");
};
run(&["add", "-A"]);
run(&["commit", "-m", message]);
git_run(repo.workdir().expect("workdir"), &["add", "-A"]);
git_run(repo.workdir().expect("workdir"), &["commit", "-m", message]);
}
/// Run a git command in `dir`, asserting success.
fn git_run(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "Test Author")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test Author")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.env("GIT_EDITOR", "true")
.args(args)
.status()
.expect("spawn git");
assert!(status.success(), "git {args:?} failed");
}
#[test]
@@ -572,4 +671,107 @@ mod tests {
let (_dir, repo) = fixture(&[("main.rs", b"")]);
assert!(find_readme(&repo).expect("find").is_none());
}
#[test]
fn worktree_branches_and_tags_list_short_names() {
let (dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "initial");
let dir = dir.path();
git_run(dir, &["checkout", "-b", "feature"]);
git_run(dir, &["tag", "v0.9"]);
git_run(dir, &["tag", "v1.0"]);
// The initial branch name depends on git configuration; only the
// branch we created is fixed.
let branches = worktree_branches(dir).expect("branches");
assert_eq!(branches.len(), 2);
assert!(branches.contains(&"feature".to_string()));
assert!(branches.windows(2).all(|pair| pair[0] <= pair[1]), "sorted");
assert_eq!(
worktree_tags(dir).expect("tags"),
vec!["v0.9".to_string(), "v1.0".to_string()]
);
}
#[test]
fn current_branch_tracks_checkout() {
let (dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "initial");
let dir = dir.path();
let default = worktree_branches(dir)
.expect("branches")
.into_iter()
.next()
.expect("default branch");
assert_eq!(
current_branch(&repo).expect("branch").as_deref(),
Some(default.as_str())
);
git_run(dir, &["checkout", "-b", "feature"]);
assert_eq!(
current_branch(&repo).expect("branch").as_deref(),
Some("feature")
);
// Tags detach HEAD.
git_run(dir, &["tag", "v1.0"]);
worktree_checkout_tag(dir, "v1.0").expect("checkout tag");
assert_eq!(current_branch(&repo).expect("branch"), None);
// Branches re-attach HEAD.
worktree_checkout_branch(dir, &default).expect("checkout branch");
assert_eq!(
current_branch(&repo).expect("branch").as_deref(),
Some(default.as_str())
);
}
#[test]
fn worktree_snapshot_reflects_checked_out_ref() {
let (dir, repo) = fixture(&[("README.md", b"# main"), ("a.txt", b"one")]);
commit_all(&repo, "initial");
let dir = dir.path();
git_run(dir, &["checkout", "-b", "feature"]);
std::fs::write(dir.join("README.md"), b"# feature").expect("write");
std::fs::write(dir.join("b.txt"), b"b").expect("write");
commit_all(&repo, "feature work");
let snapshot = worktree_snapshot(dir).expect("snapshot");
assert_eq!(snapshot.current_branch.as_deref(), Some("feature"));
assert_eq!(
String::from_utf8(snapshot.readme.expect("readme")).expect("utf8"),
"# feature"
);
let entries: Vec<String> = snapshot
.entries
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
assert!(entries.contains(&"b.txt".to_string()));
let default = worktree_branches(dir)
.expect("branches")
.into_iter()
.find(|name| name != "feature")
.expect("default branch");
worktree_checkout_branch(dir, &default).expect("checkout");
let snapshot = worktree_snapshot(dir).expect("snapshot");
assert_eq!(snapshot.current_branch.as_deref(), Some(default.as_str()));
assert_eq!(
String::from_utf8(snapshot.readme.expect("readme")).expect("utf8"),
"# main"
);
assert!(
!snapshot
.entries
.iter()
.any(|p| p.to_string_lossy() == "b.txt")
);
}
}