use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::Duration;
use git2::Repository;
use serde_json::Value;
#[cfg(unix)]
use std::os::unix::io::AsRawFd;
use super::metadata::{self, BranchMetadata, StackMetadata};
use super::Stack;
use crate::error::StackError;
#[derive(Debug)]
struct GhStackBranchRef {
branch: String,
base: String,
}
impl GhStackBranchRef {
fn from_value(value: &Value) -> Option<Self> {
Some(Self {
branch: value.get("branch")?.as_str()?.to_string(),
base: value
.get("base")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
})
}
}
#[derive(Debug)]
struct GhStackEntry {
id: String,
number: u64,
trunk: GhStackBranchRef,
branches: Vec<GhStackBranchRef>,
}
impl GhStackEntry {
fn from_value(value: &Value) -> Option<Self> {
let trunk = GhStackBranchRef::from_value(value.get("trunk")?)?;
let branches = value
.get("branches")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(GhStackBranchRef::from_value)
.collect()
})
.unwrap_or_default();
Some(Self {
id: value
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
number: value.get("number").and_then(|v| v.as_u64()).unwrap_or(0),
trunk,
branches,
})
}
}
fn parse_stacks(doc: &Value) -> Vec<GhStackEntry> {
doc.get("stacks")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(GhStackEntry::from_value).collect())
.unwrap_or_default()
}
const READ_ATTEMPTS: u32 = 3;
const READ_RETRY_DELAY: Duration = Duration::from_millis(25);
pub(crate) fn canonical_path(repo: &Repository) -> PathBuf {
repo.commondir().join("gh-stack")
}
pub(crate) fn unlinked_files(repo: &Repository) -> Vec<PathBuf> {
let canonical = canonical_path(repo);
let worktrees_dir = repo.commondir().join("worktrees");
let Ok(entries) = std::fs::read_dir(&worktrees_dir) else {
return vec![];
};
let mut names: Vec<String> = entries
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.filter_map(|e| e.file_name().into_string().ok())
.collect();
names.sort();
names
.into_iter()
.filter_map(|name| {
let path = worktrees_dir.join(&name).join("gh-stack");
if !path_exists_at_all(&path) || is_symlink_resolving_to(&path, &canonical) {
None
} else {
Some(path)
}
})
.collect()
}
fn path_exists_at_all(path: &Path) -> bool {
std::fs::symlink_metadata(path).is_ok()
}
fn is_symlink_resolving_to(path: &Path, canonical: &Path) -> bool {
let Ok(meta) = std::fs::symlink_metadata(path) else {
return false;
};
if !meta.file_type().is_symlink() {
return false;
}
let Ok(target) = std::fs::read_link(path) else {
return false;
};
let Some(parent) = path.parent() else {
return false;
};
normalize_lexically(&parent.join(target)) == normalize_lexically(canonical)
}
fn normalize_lexically(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
out.pop();
}
std::path::Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
pub(crate) fn is_gh_stack_repo(repo: &Repository) -> bool {
canonical_path(repo).exists() || !unlinked_files(repo).is_empty()
}
fn read_doc(path: &Path) -> Result<Option<Vec<GhStackEntry>>, StackError> {
let mut last_error: Option<String> = None;
for attempt in 0..READ_ATTEMPTS {
match std::fs::read(path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => last_error = Some(e.to_string()),
Ok(bytes) => match serde_json::from_slice::<Value>(&bytes) {
Err(e) => last_error = Some(e.to_string()),
Ok(value) => {
let version = value
.get("schemaVersion")
.and_then(|v| v.as_u64())
.filter(|&v| v != 0)
.unwrap_or(1);
if version > 1 {
return Err(StackError::GhStackSchemaUnsupported {
path: path.to_path_buf(),
version,
});
}
return Ok(Some(parse_stacks(&value)));
}
},
}
if attempt + 1 < READ_ATTEMPTS {
std::thread::sleep(READ_RETRY_DELAY);
}
}
log::warn!(
"gh-stack: skipping unreadable file {}: {}",
path.display(),
last_error.unwrap_or_default()
);
Ok(None)
}
#[derive(Debug, PartialEq, Eq, Hash)]
enum StackIdentity {
Number(u64),
Id(String),
TrunkAndFirstBranch(String, String),
}
fn identity(entry: &GhStackEntry) -> StackIdentity {
if entry.number != 0 {
StackIdentity::Number(entry.number)
} else if !entry.id.is_empty() {
StackIdentity::Id(entry.id.clone())
} else {
let first_branch = entry
.branches
.first()
.map(|b| b.branch.clone())
.unwrap_or_default();
StackIdentity::TrunkAndFirstBranch(entry.trunk.branch.clone(), first_branch)
}
}
pub(crate) fn read_metadata(repo: &Repository) -> Result<StackMetadata, StackError> {
let mut seen: HashSet<StackIdentity> = HashSet::new();
let mut kept: Vec<GhStackEntry> = Vec::new();
let mut sources = vec![canonical_path(repo)];
sources.extend(unlinked_files(repo));
for path in sources {
let Some(entries) = read_doc(&path)? else {
continue;
};
for entry in entries {
if seen.insert(identity(&entry)) {
kept.push(entry);
}
}
}
let mut trunks: Vec<String> = Vec::new();
let mut parents: HashMap<String, BranchMetadata> = HashMap::new();
let mut stack_numbers: HashMap<String, u64> = HashMap::new();
for entry in &kept {
if !trunks.contains(&entry.trunk.branch) {
trunks.push(entry.trunk.branch.clone());
}
let mut parent = entry.trunk.branch.clone();
for branch_ref in &entry.branches {
let parent_revision = if branch_ref.base.is_empty() {
None
} else {
Some(branch_ref.base.clone())
};
parents
.entry(branch_ref.branch.clone())
.or_insert(BranchMetadata {
parent: parent.clone(),
parent_revision,
});
if entry.number != 0 {
stack_numbers
.entry(branch_ref.branch.clone())
.or_insert(entry.number);
}
parent = branch_ref.branch.clone();
}
}
Ok(StackMetadata {
trunks,
parents,
pr_titles: HashMap::new(),
stack_numbers,
})
}
pub(crate) fn enumerate_stacks(repo: &Repository) -> Result<Vec<Stack>, StackError> {
Ok(metadata::enumerate(repo, &read_metadata(repo)?))
}
pub(crate) fn current_stack(
repo: &Repository,
head_branch: &str,
) -> Result<Option<Stack>, StackError> {
Ok(metadata::current(&read_metadata(repo)?, head_branch))
}
#[cfg(unix)]
struct LockGuard(std::fs::File);
#[cfg(unix)]
impl Drop for LockGuard {
fn drop(&mut self) {
unsafe {
libc::flock(self.0.as_raw_fd(), libc::LOCK_UN);
}
}
}
#[cfg(not(unix))]
struct LockGuard;
const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
const LOCK_RETRY_DELAY: Duration = Duration::from_millis(100);
#[cfg(unix)]
fn lock_canonical(repo: &Repository) -> Result<LockGuard, StackError> {
let lock_path = repo.commondir().join("gh-stack.lock");
let file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false) .open(&lock_path)
.map_err(|e| StackError::GhStackWriteFailed {
path: lock_path.clone(),
message: e.to_string(),
})?;
let deadline = std::time::Instant::now() + LOCK_TIMEOUT;
loop {
let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if ret == 0 {
return Ok(LockGuard(file));
}
let err = std::io::Error::last_os_error();
if err.raw_os_error() != Some(libc::EWOULDBLOCK) || std::time::Instant::now() >= deadline {
return Err(StackError::GhStackLocked { path: lock_path });
}
std::thread::sleep(LOCK_RETRY_DELAY);
}
}
#[cfg(not(unix))]
fn lock_canonical(_repo: &Repository) -> Result<LockGuard, StackError> {
Ok(LockGuard)
}
#[cfg(unix)]
fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_file(target, link)
}
fn plant_link(admin_dir: &Path, filename: &str) -> Result<(), StackError> {
let link_path = admin_dir.join(filename);
let relative_target = Path::new("..").join("..").join(filename);
match std::fs::symlink_metadata(&link_path) {
Ok(meta) if meta.file_type().is_symlink() => {
if std::fs::read_link(&link_path).ok().as_deref() == Some(relative_target.as_path()) {
return Ok(()); }
std::fs::remove_file(&link_path).map_err(|e| StackError::GhStackLinkFailed {
path: link_path.clone(),
message: e.to_string(),
})?;
create_symlink(&relative_target, &link_path).map_err(|e| {
StackError::GhStackLinkFailed {
path: link_path,
message: e.to_string(),
}
})
}
Ok(_) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
create_symlink(&relative_target, &link_path).map_err(|e| {
StackError::GhStackLinkFailed {
path: link_path,
message: e.to_string(),
}
})
}
Err(e) => Err(StackError::GhStackLinkFailed {
path: link_path,
message: e.to_string(),
}),
}
}
pub(crate) fn link_worktree(repo: &Repository, worktree_name: &str) -> Result<(), StackError> {
let admin_dir = repo.commondir().join("worktrees").join(worktree_name);
plant_link(&admin_dir, "gh-stack")?;
plant_link(&admin_dir, "gh-stack.lock")?;
Ok(())
}
fn raw_identity(entry: &Value) -> StackIdentity {
let number = entry.get("number").and_then(|v| v.as_u64()).unwrap_or(0);
if number != 0 {
return StackIdentity::Number(number);
}
let id = entry.get("id").and_then(|v| v.as_str()).unwrap_or_default();
if !id.is_empty() {
return StackIdentity::Id(id.to_string());
}
let trunk = entry
.get("trunk")
.and_then(|t| t.get("branch"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let first_branch = entry
.get("branches")
.and_then(|b| b.as_array())
.and_then(|arr| arr.first())
.and_then(|b| b.get("branch"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
StackIdentity::TrunkAndFirstBranch(trunk, first_branch)
}
fn read_raw_doc(path: &Path) -> Result<Option<Value>, StackError> {
match std::fs::read(path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(StackError::GhStackParseFailed {
path: path.to_path_buf(),
message: e.to_string(),
}),
Ok(bytes) => {
let value: Value =
serde_json::from_slice(&bytes).map_err(|e| StackError::GhStackParseFailed {
path: path.to_path_buf(),
message: e.to_string(),
})?;
let version = value
.get("schemaVersion")
.and_then(|v| v.as_u64())
.filter(|&v| v != 0)
.unwrap_or(1);
if version > 1 {
return Err(StackError::GhStackSchemaUnsupported {
path: path.to_path_buf(),
version,
});
}
Ok(Some(value))
}
}
}
fn read_raw_stacks(path: &Path) -> Result<Vec<Value>, StackError> {
Ok(read_raw_doc(path)?
.and_then(|doc| doc.get("stacks").and_then(|v| v.as_array()).cloned())
.unwrap_or_default())
}
pub(crate) fn migrate_worktree(repo: &Repository, worktree_name: &str) -> Result<(), StackError> {
let admin_dir = repo.commondir().join("worktrees").join(worktree_name);
let worktree_file = admin_dir.join("gh-stack");
let is_regular_file = matches!(
std::fs::symlink_metadata(&worktree_file),
Ok(meta) if !meta.file_type().is_symlink()
);
if !is_regular_file {
remove_stale_lock_file(&admin_dir)?;
return link_worktree(repo, worktree_name);
}
let _lock = lock_canonical(repo)?;
let canonical = canonical_path(repo);
let canonical_doc = read_raw_doc(&canonical)?;
let mut doc = match &canonical_doc {
Some(v) => v.clone(),
None => read_raw_doc(&worktree_file)?
.unwrap_or_else(|| serde_json::json!({ "schemaVersion": 1, "stacks": [] })),
};
let mut merged: Vec<Value> = canonical_doc
.as_ref()
.and_then(|v| v.get("stacks"))
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut seen: HashSet<StackIdentity> = merged.iter().map(raw_identity).collect();
for entry in read_raw_stacks(&worktree_file)? {
if seen.insert(raw_identity(&entry)) {
merged.push(entry);
}
}
doc["schemaVersion"] = serde_json::json!(1);
doc["stacks"] = serde_json::Value::Array(merged);
let bytes = serde_json::to_vec_pretty(&doc).map_err(|e| StackError::GhStackWriteFailed {
path: canonical.clone(),
message: e.to_string(),
})?;
serde_json::from_slice::<Value>(&bytes).map_err(|e| StackError::GhStackParseFailed {
path: canonical.clone(),
message: e.to_string(),
})?;
let tmp_path = canonical.with_extension("tmp");
std::fs::write(&tmp_path, &bytes).map_err(|e| StackError::GhStackWriteFailed {
path: tmp_path.clone(),
message: e.to_string(),
})?;
std::fs::rename(&tmp_path, &canonical).map_err(|e| StackError::GhStackWriteFailed {
path: canonical.clone(),
message: e.to_string(),
})?;
read_raw_stacks(&canonical)?;
let bak_path = next_available_backup_path(&admin_dir);
std::fs::rename(&worktree_file, &bak_path).map_err(|e| StackError::GhStackWriteFailed {
path: worktree_file.clone(),
message: e.to_string(),
})?;
remove_stale_lock_file(&admin_dir)?;
link_worktree(repo, worktree_name)
}
fn remove_stale_lock_file(admin_dir: &Path) -> Result<(), StackError> {
let lock_path = admin_dir.join("gh-stack.lock");
let is_regular_file = matches!(
std::fs::symlink_metadata(&lock_path),
Ok(meta) if !meta.file_type().is_symlink()
);
if is_regular_file {
std::fs::remove_file(&lock_path).map_err(|e| StackError::GhStackWriteFailed {
path: lock_path,
message: e.to_string(),
})?;
}
Ok(())
}
fn next_available_backup_path(admin_dir: &Path) -> PathBuf {
let base = admin_dir.join("gh-stack.bak");
if std::fs::symlink_metadata(&base).is_err() {
return base;
}
(1u32..)
.map(|n| admin_dir.join(format!("gh-stack.bak.{n}")))
.find(|candidate| std::fs::symlink_metadata(candidate).is_err())
.expect("u32 backup suffixes are effectively inexhaustible")
}
fn branch_tip(repo: &Repository, name: &str) -> Result<git2::Oid, StackError> {
let branch = repo
.find_branch(name, git2::BranchType::Local)
.map_err(|e| StackError::GhStackWriteFailed {
path: canonical_path(repo),
message: format!("branch '{name}' not found: {e}"),
})?;
branch
.get()
.target()
.ok_or_else(|| StackError::GhStackWriteFailed {
path: canonical_path(repo),
message: format!("branch '{name}' has no target (unborn?)"),
})
}
fn select_target_index(stacks: &[Value], base_branch: &str) -> Option<usize> {
stacks
.iter()
.position(|stack| {
stack
.get("branches")
.and_then(|b| b.as_array())
.and_then(|arr| arr.last())
.and_then(|b| b.get("branch"))
.and_then(|v| v.as_str())
== Some(base_branch)
})
.or_else(|| {
stacks.iter().position(|stack| {
let branches_empty = stack
.get("branches")
.and_then(|b| b.as_array())
.map(|arr| arr.is_empty())
.unwrap_or(true);
branches_empty
&& stack
.get("trunk")
.and_then(|t| t.get("branch"))
.and_then(|v| v.as_str())
== Some(base_branch)
})
})
}
fn plan_registered_doc(
existing: &[u8],
branch: &str,
base_branch: &str,
base: &str,
head: &str,
canonical: &Path,
) -> Result<Vec<u8>, StackError> {
let mut doc: Value = if existing.is_empty() {
serde_json::json!({ "schemaVersion": 1, "stacks": [] })
} else {
serde_json::from_slice(existing).map_err(|e| StackError::GhStackParseFailed {
path: canonical.to_path_buf(),
message: e.to_string(),
})?
};
let version = doc
.get("schemaVersion")
.and_then(|v| v.as_u64())
.filter(|&v| v != 0)
.unwrap_or(1);
if version > 1 {
return Err(StackError::GhStackSchemaUnsupported {
path: canonical.to_path_buf(),
version,
});
}
let stacks = doc
.get_mut("stacks")
.and_then(|v| v.as_array_mut())
.ok_or_else(|| StackError::GhStackNoStackForBase {
base: base_branch.to_string(),
})?;
let idx = select_target_index(stacks, base_branch).ok_or_else(|| {
StackError::GhStackNoStackForBase {
base: base_branch.to_string(),
}
})?;
let new_entry = serde_json::json!({ "branch": branch, "head": head, "base": base });
match stacks[idx]
.get_mut("branches")
.and_then(|v| v.as_array_mut())
{
Some(arr) => arr.push(new_entry),
None => stacks[idx]["branches"] = serde_json::json!([new_entry]),
}
serde_json::to_vec_pretty(&doc).map_err(|e| StackError::GhStackWriteFailed {
path: canonical.to_path_buf(),
message: e.to_string(),
})
}
fn write_canonical_atomic(canonical: &Path, bytes: &[u8]) -> Result<(), StackError> {
let tmp_path = canonical.with_extension("tmp");
std::fs::write(&tmp_path, bytes).map_err(|e| StackError::GhStackWriteFailed {
path: tmp_path.clone(),
message: e.to_string(),
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o644)).map_err(
|e| StackError::GhStackWriteFailed {
path: tmp_path.clone(),
message: e.to_string(),
},
)?;
}
std::fs::rename(&tmp_path, canonical).map_err(|e| StackError::GhStackWriteFailed {
path: canonical.to_path_buf(),
message: e.to_string(),
})
}
pub fn register_branch(
repo: &Repository,
branch: &str,
base_branch: &str,
) -> Result<(), StackError> {
let head = branch_tip(repo, branch)?;
let base_tip = branch_tip(repo, base_branch)?;
let base = repo.merge_base(base_tip, head).unwrap_or(base_tip);
let canonical = canonical_path(repo);
let _lock = lock_canonical(repo)?;
let existing = std::fs::read(&canonical).unwrap_or_default();
let new_bytes = match plan_registered_doc(
&existing,
branch,
base_branch,
&base.to_string(),
&head.to_string(),
&canonical,
) {
Err(StackError::GhStackNoStackForBase { base }) if !unlinked_files(repo).is_empty() => {
return Err(StackError::GhStackStackInUnlinkedWorktree { base });
}
Err(e) => return Err(e),
Ok(bytes) => bytes,
};
write_canonical_atomic(&canonical, &new_bytes)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinkStatus {
Linked,
NotLinked { holds_file: bool },
}
fn link_status_for_path(path: &Path, expected_target: &Path) -> LinkStatus {
match std::fs::symlink_metadata(path) {
Err(_) => LinkStatus::NotLinked { holds_file: false },
Ok(meta) if meta.file_type().is_symlink() => {
if is_symlink_resolving_to(path, expected_target) {
LinkStatus::Linked
} else {
LinkStatus::NotLinked { holds_file: false }
}
}
Ok(_) => LinkStatus::NotLinked { holds_file: true },
}
}
pub(crate) fn worktree_link_status(repo: &Repository, worktree_name: &str) -> LinkStatus {
let admin_dir = repo.commondir().join("worktrees").join(worktree_name);
let canonical = canonical_path(repo);
let canonical_lock = repo.commondir().join("gh-stack.lock");
let gh_stack_status = link_status_for_path(&admin_dir.join("gh-stack"), &canonical);
if matches!(gh_stack_status, LinkStatus::NotLinked { .. }) {
return gh_stack_status;
}
match link_status_for_path(&admin_dir.join("gh-stack.lock"), &canonical_lock) {
LinkStatus::Linked => LinkStatus::Linked,
LinkStatus::NotLinked { .. } => LinkStatus::NotLinked { holds_file: false },
}
}
pub(crate) fn readability_errors(repo: &Repository) -> Vec<(PathBuf, StackError)> {
let mut sources = vec![canonical_path(repo)];
sources.extend(unlinked_files(repo));
sources
.into_iter()
.filter(|path| path.exists())
.filter_map(|path| match read_raw_stacks(&path) {
Ok(_) => None,
Err(e) => Some((path, e)),
})
.collect()
}
pub(crate) fn divergent_stack_numbers(repo: &Repository) -> Vec<u64> {
let mut sources = vec![canonical_path(repo)];
sources.extend(unlinked_files(repo));
let mut signatures_by_number: HashMap<u64, Vec<Vec<String>>> = HashMap::new();
for path in &sources {
if let Ok(Some(entries)) = read_doc(path) {
let mut numbers_in_this_source: HashSet<u64> = HashSet::new();
for entry in entries {
if entry.number != 0 && numbers_in_this_source.insert(entry.number) {
let branches: Vec<String> =
entry.branches.iter().map(|b| b.branch.clone()).collect();
signatures_by_number
.entry(entry.number)
.or_default()
.push(branches);
}
}
}
}
let mut divergent: Vec<u64> = signatures_by_number
.into_iter()
.filter(|(_, signatures)| signatures.iter().any(|s| s != &signatures[0]))
.map(|(number, _)| number)
.collect();
divergent.sort_unstable();
divergent
}
#[cfg(test)]
mod tests {
use super::*;
use git_workon_fixture::prelude::*;
#[test]
fn reads_linear_stack_from_canonical() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.gh_stack(None, 12, "main", &["feat-a", "feat-b"])
.build()
.unwrap();
let repo = fixture.repo().unwrap();
let meta = read_metadata(repo).unwrap();
assert_eq!(meta.trunks, vec!["main".to_string()]);
assert_eq!(meta.parents["feat-a"].parent, "main");
assert_eq!(meta.parents["feat-b"].parent, "feat-a");
assert_eq!(meta.stack_numbers["feat-a"], 12);
assert_eq!(meta.stack_numbers["feat-b"], 12);
let stacks = enumerate_stacks(repo).unwrap();
assert_eq!(stacks.len(), 1);
assert_eq!(stacks[0].number, Some(12));
assert_eq!(stacks[0].diffs, vec!["feat-a", "feat-b"]);
}
#[test]
fn ghost_retained_by_current_stack_and_pruned_by_enumerate() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.gh_stack(None, 5, "main", &["feat-a"])
.gh_stack_ghost_branch(None, 5, "feat-b")
.build()
.unwrap();
let repo = fixture.repo().unwrap();
let current = current_stack(repo, "feat-a").unwrap().expect("tracked");
assert!(current.diffs.contains(&"feat-a".to_string()));
let enumerated = enumerate_stacks(repo).unwrap();
assert_eq!(enumerated.len(), 1);
assert!(!enumerated[0].diffs.contains(&"feat-b".to_string()));
assert!(enumerated[0].diffs.contains(&"feat-a".to_string()));
}
#[test]
fn truncated_file_is_skipped() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.raw_gh_stack(None, b"{\"schemaVersion\": 1, \"stacks\": [".to_vec())
.build()
.unwrap();
let repo = fixture.repo().unwrap();
let meta = read_metadata(repo).unwrap();
assert!(meta.trunks.is_empty());
assert!(meta.parents.is_empty());
}
#[test]
fn schema_version_2_is_a_hard_error() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.raw_gh_stack(None, br#"{"schemaVersion": 2, "stacks": []}"#.to_vec())
.build()
.unwrap();
let repo = fixture.repo().unwrap();
match read_metadata(repo) {
Err(StackError::GhStackSchemaUnsupported { version: 2, .. }) => {}
Err(e) => panic!("expected GhStackSchemaUnsupported{{version: 2}}, got {e:?}"),
Ok(_) => panic!("expected GhStackSchemaUnsupported{{version: 2}}, got Ok"),
}
}
#[test]
fn missing_schema_version_defaults_to_1() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.branch("feat-a")
.raw_gh_stack(
None,
br#"{"stacks": [{"number": 1, "trunk": {"branch": "main", "head": "", "base": ""}, "branches": [{"branch": "feat-a", "head": "", "base": ""}]}]}"#.to_vec(),
)
.build()
.unwrap();
let repo = fixture.repo().unwrap();
let meta = read_metadata(repo).unwrap();
assert_eq!(meta.parents["feat-a"].parent, "main");
assert_eq!(meta.stack_numbers["feat-a"], 1);
}
#[test]
fn schema_version_0_defaults_to_1() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.branch("feat-a")
.raw_gh_stack(
None,
br#"{"schemaVersion": 0, "stacks": [{"number": 1, "trunk": {"branch": "main", "head": "", "base": ""}, "branches": [{"branch": "feat-a", "head": "", "base": ""}]}]}"#.to_vec(),
)
.build()
.unwrap();
let repo = fixture.repo().unwrap();
let meta = read_metadata(repo).unwrap();
assert_eq!(meta.parents["feat-a"].parent, "main");
assert_eq!(meta.stack_numbers["feat-a"], 1);
}
#[test]
fn needs_restack_true_when_base_differs_from_parent_live_tip() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.gh_stack_at(
None,
1,
"main",
&[("feat-a", "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")],
)
.build()
.unwrap();
let repo = fixture.repo().unwrap();
let meta = read_metadata(repo).unwrap();
let entry = meta.parents.get("feat-a").unwrap();
assert_eq!(
entry.parent_revision.as_deref(),
Some("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
);
let main_tip = repo
.find_branch("main", git2::BranchType::Local)
.unwrap()
.get()
.target()
.unwrap();
assert_ne!(
entry.parent_revision.as_deref(),
Some(main_tip.to_string().as_str())
);
}
#[test]
fn degraded_union_pulls_in_unlinked_worktree_file() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.worktree("feat-a")
.gh_stack(Some("feat-a"), 9, "main", &["feat-a"])
.build()
.unwrap();
let repo = fixture.repo().unwrap();
let meta = read_metadata(repo).unwrap();
assert_eq!(meta.parents["feat-a"].parent, "main");
assert_eq!(meta.stack_numbers["feat-a"], 9);
}
#[test]
fn degraded_union_first_wins_on_disagreeing_unlinked_files() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.worktree("feat-a")
.worktree("feat-b")
.gh_stack(Some("feat-a"), 1, "main", &["feat-a"])
.gh_stack(Some("feat-b"), 1, "main", &["feat-b"])
.build()
.unwrap();
let repo = fixture.repo().unwrap();
let meta = read_metadata(repo).unwrap();
assert!(meta.parents.contains_key("feat-a"));
assert!(!meta.parents.contains_key("feat-b"));
}
#[test]
fn two_numbered_stacks_in_one_file_are_not_divergent() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.branch("other-trunk")
.worktree("main")
.gh_stack(None, 1, "main", &["feat-a"])
.gh_stack(None, 1, "other-trunk", &["feat-b"])
.build()
.unwrap();
let repo = fixture.repo().unwrap();
assert!(divergent_stack_numbers(repo).is_empty());
}
#[test]
fn identical_copy_across_canonical_and_unlinked_is_not_divergent() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.worktree("feat-a")
.gh_stack(None, 4, "main", &["feat-a"])
.gh_stack(Some("feat-a"), 4, "main", &["feat-a"])
.build()
.unwrap();
let repo = fixture.repo().unwrap();
assert!(divergent_stack_numbers(repo).is_empty());
}
#[test]
fn genuinely_differing_copy_across_sources_is_divergent() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.worktree("feat-a")
.branch("feat-b")
.gh_stack(None, 4, "main", &["feat-a"])
.gh_stack(Some("feat-a"), 4, "main", &["feat-b"])
.build()
.unwrap();
let repo = fixture.repo().unwrap();
assert_eq!(divergent_stack_numbers(repo), vec![4]);
}
#[test]
fn branch_spanning_two_stacks_keeps_the_first_stacks_parent_and_number() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.branch("other-trunk")
.worktree("main")
.gh_stack(None, 1, "main", &["shared"])
.gh_stack(None, 2, "other-trunk", &["shared"])
.build()
.unwrap();
let repo = fixture.repo().unwrap();
let meta = read_metadata(repo).unwrap();
assert_eq!(meta.parents["shared"].parent, "main");
assert_eq!(meta.stack_numbers["shared"], 1);
}
#[test]
fn link_worktree_plants_relative_symlinks() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.worktree("feat-a")
.build()
.unwrap();
let repo = fixture.repo().unwrap();
link_worktree(repo, "feat-a").unwrap();
repo.assert(predicate::repo::gh_stack_is_linked("feat-a"));
let lock_target = std::fs::read_link(
repo.commondir()
.join("worktrees")
.join("feat-a")
.join("gh-stack.lock"),
)
.unwrap();
assert_eq!(lock_target, Path::new("../../gh-stack.lock"));
}
#[test]
fn link_worktree_is_idempotent() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.worktree("feat-a")
.build()
.unwrap();
let repo = fixture.repo().unwrap();
link_worktree(repo, "feat-a").unwrap();
link_worktree(repo, "feat-a").unwrap();
repo.assert(predicate::repo::gh_stack_is_linked("feat-a"));
}
#[test]
fn link_worktree_replaces_a_symlink_pointing_somewhere_wrong() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.worktree("feat-a")
.build()
.unwrap();
let repo = fixture.repo().unwrap();
let admin_dir = repo.commondir().join("worktrees").join("feat-a");
create_symlink(Path::new("../../nonsense"), &admin_dir.join("gh-stack")).unwrap();
link_worktree(repo, "feat-a").unwrap();
repo.assert(predicate::repo::gh_stack_is_linked("feat-a"));
}
#[test]
fn link_worktree_never_replaces_a_real_file() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.worktree("feat-a")
.gh_stack(Some("feat-a"), 3, "main", &["feat-a"])
.gh_stack_unlinked("feat-a")
.build()
.unwrap();
let repo = fixture.repo().unwrap();
link_worktree(repo, "feat-a").unwrap();
repo.assert(predicate::repo::gh_stack_contains_branch(
Some("feat-a"),
"feat-a",
0,
));
let meta =
std::fs::symlink_metadata(repo.commondir().join("worktrees/feat-a/gh-stack")).unwrap();
assert!(!meta.file_type().is_symlink());
}
#[test]
fn migrate_worktree_merges_into_canonical_and_leaves_backup() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.worktree("feat-a")
.gh_stack(Some("feat-a"), 7, "main", &["feat-a"])
.gh_stack_unlinked("feat-a")
.build()
.unwrap();
let repo = fixture.repo().unwrap();
migrate_worktree(repo, "feat-a").unwrap();
repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-a", 0));
repo.assert(predicate::repo::gh_stack_is_linked("feat-a"));
assert!(repo
.commondir()
.join("worktrees/feat-a/gh-stack.bak")
.exists());
let meta = read_metadata(repo).unwrap();
assert_eq!(meta.stack_numbers["feat-a"], 7);
}
#[test]
fn migrate_worktree_preserves_top_level_fields_when_canonical_is_absent() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.worktree("feat-a")
.gh_stack(Some("feat-a"), 7, "main", &["feat-a"])
.build()
.unwrap();
let repo = fixture.repo().unwrap();
migrate_worktree(repo, "feat-a").unwrap();
repo.assert(predicate::repo::gh_stack_preserves(
None,
"/repository",
"git-workon-fixture/gh-stack",
));
repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-a", 0));
}
#[test]
fn migrate_worktree_falls_back_to_link_when_nothing_to_merge() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.worktree("feat-a")
.build()
.unwrap();
let repo = fixture.repo().unwrap();
migrate_worktree(repo, "feat-a").unwrap();
repo.assert(predicate::repo::gh_stack_is_linked("feat-a"));
assert!(!repo
.commondir()
.join("worktrees/feat-a/gh-stack.bak")
.exists());
}
#[test]
fn migrate_worktree_never_clobbers_an_existing_backup() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.worktree("feat-a")
.gh_stack(Some("feat-a"), 7, "main", &["feat-a"])
.gh_stack_unlinked("feat-a")
.build()
.unwrap();
let repo = fixture.repo().unwrap();
migrate_worktree(repo, "feat-a").unwrap();
let admin_dir = repo.commondir().join("worktrees/feat-a");
let first_backup = admin_dir.join("gh-stack.bak");
assert!(first_backup.exists());
let first_backup_contents = std::fs::read(&first_backup).unwrap();
std::fs::remove_file(admin_dir.join("gh-stack")).unwrap();
std::fs::write(
admin_dir.join("gh-stack"),
br#"{"schemaVersion":1,"stacks":[]}"#,
)
.unwrap();
migrate_worktree(repo, "feat-a").unwrap();
assert_eq!(std::fs::read(&first_backup).unwrap(), first_backup_contents);
assert!(admin_dir.join("gh-stack.bak.1").exists());
repo.assert(predicate::repo::gh_stack_is_linked("feat-a"));
}
#[test]
fn migrate_worktree_also_migrates_a_stale_lock_file() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.worktree("feat-a")
.gh_stack(Some("feat-a"), 7, "main", &["feat-a"])
.gh_stack_unlinked("feat-a")
.gh_stack_lock_unlinked("feat-a")
.build()
.unwrap();
let repo = fixture.repo().unwrap();
let admin_dir = repo.commondir().join("worktrees/feat-a");
let lock_meta_before = std::fs::symlink_metadata(admin_dir.join("gh-stack.lock")).unwrap();
assert!(!lock_meta_before.file_type().is_symlink());
migrate_worktree(repo, "feat-a").unwrap();
repo.assert(predicate::repo::gh_stack_is_linked("feat-a"));
let lock_meta_after = std::fs::symlink_metadata(admin_dir.join("gh-stack.lock")).unwrap();
assert!(
lock_meta_after.file_type().is_symlink(),
"gh-stack.lock must be a symlink after migration"
);
let lock_target = std::fs::read_link(admin_dir.join("gh-stack.lock")).unwrap();
assert_eq!(lock_target, Path::new("../../gh-stack.lock"));
}
#[test]
fn worktree_link_status_reports_linked_for_a_fully_linked_worktree() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.worktree("feat-a")
.gh_stack(None, 1, "main", &["feat-a"])
.gh_stack_linked("feat-a")
.build()
.unwrap();
let repo = fixture.repo().unwrap();
assert_eq!(worktree_link_status(repo, "feat-a"), LinkStatus::Linked);
assert!(unlinked_files(repo).is_empty());
assert!(divergent_stack_numbers(repo).is_empty());
}
#[test]
fn worktree_link_status_reports_not_linked_when_lock_is_a_regular_file() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.worktree("feat-a")
.gh_stack_linked("feat-a")
.gh_stack_lock_unlinked("feat-a")
.build()
.unwrap();
let repo = fixture.repo().unwrap();
match worktree_link_status(repo, "feat-a") {
LinkStatus::NotLinked { holds_file } => {
assert!(!holds_file);
}
LinkStatus::Linked => panic!("expected NotLinked, got Linked"),
}
}
#[test]
fn register_branch_appends_onto_a_trunk_with_no_branches_yet() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.branch("feat-a")
.gh_stack(None, 1, "main", &[])
.build()
.unwrap();
let repo = fixture.repo().unwrap();
register_branch(repo, "feat-a", "main").unwrap();
repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-a", 0));
let head_oid = repo
.find_branch("feat-a", git2::BranchType::Local)
.unwrap()
.get()
.target()
.unwrap();
repo.assert(predicate::repo::gh_stack_branch_base(
None,
"feat-a",
head_oid.to_string(),
));
}
#[test]
fn register_branch_appends_onto_the_top_of_an_existing_stack() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.gh_stack(None, 1, "main", &["feat-a"])
.branch("feat-b")
.build()
.unwrap();
let repo = fixture.repo().unwrap();
register_branch(repo, "feat-b", "feat-a").unwrap();
repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-a", 0));
repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-b", 1));
}
#[test]
fn register_branch_preserves_id_and_pull_request_on_untouched_entries() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.branch("feat-a")
.branch("feat-b")
.raw_gh_stack(
None,
br#"{
"schemaVersion": 1,
"stacks": [{
"id": "stack-abc",
"number": 3,
"trunk": { "branch": "main", "head": "", "base": "" },
"branches": [{
"branch": "feat-a",
"head": "0000000000000000000000000000000000000a",
"base": "0000000000000000000000000000000000000b",
"pullRequest": { "number": 42, "id": "PR_1", "merged": false }
}]
}]
}"#
.to_vec(),
)
.build()
.unwrap();
let repo = fixture.repo().unwrap();
register_branch(repo, "feat-b", "feat-a").unwrap();
repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-a", 0));
repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-b", 1));
repo.assert(predicate::repo::gh_stack_preserves(
None,
"/stacks/0/id",
"stack-abc",
));
repo.assert(predicate::repo::gh_stack_preserves(
None,
"/stacks/0/branches/0/pullRequest/number",
"42",
));
}
#[test]
fn register_branch_surfaces_parse_failed_for_truncated_canonical() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.branch("feat-a")
.branch("feat-b")
.gh_stack(None, 1, "main", &["feat-a"])
.raw_gh_stack(None, b"{\"schemaVersion\": 1, \"stacks\": [".to_vec())
.build()
.unwrap();
let repo = fixture.repo().unwrap();
match register_branch(repo, "feat-b", "feat-a") {
Err(StackError::GhStackParseFailed { .. }) => {}
other => panic!("expected GhStackParseFailed, got {other:?}"),
}
}
#[test]
fn register_branch_errors_when_no_stack_ends_at_base() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.branch("feat-a")
.branch("feat-b")
.gh_stack(None, 1, "main", &["feat-a"])
.build()
.unwrap();
let repo = fixture.repo().unwrap();
match register_branch(repo, "feat-b", "main") {
Err(StackError::GhStackNoStackForBase { base }) => {
assert_eq!(base, "main");
}
other => panic!("expected GhStackNoStackForBase, got {other:?}"),
}
}
#[test]
fn register_branch_points_at_doctor_fix_when_stack_is_unlinked_only() {
let fixture = FixtureBuilder::new()
.bare(true)
.default_branch("main")
.worktree("main")
.worktree("feat-a")
.branch("feat-b")
.gh_stack(Some("feat-a"), 1, "main", &["feat-a"])
.build()
.unwrap();
let repo = fixture.repo().unwrap();
let meta = read_metadata(repo).unwrap();
assert_eq!(meta.parents["feat-a"].parent, "main");
match register_branch(repo, "feat-b", "feat-a") {
Err(StackError::GhStackStackInUnlinkedWorktree { base }) => {
assert_eq!(base, "feat-a");
}
other => panic!("expected GhStackStackInUnlinkedWorktree, got {other:?}"),
}
}
}