add ref viewer
This commit is contained in:
+218
-16
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user