1use std::collections::{HashMap, HashSet};
52use std::path::{Path, PathBuf};
53use std::time::Duration;
54
55use git2::Repository;
56use serde_json::Value;
57
58#[cfg(unix)]
59use std::os::unix::io::AsRawFd;
60
61use super::metadata::{self, BranchMetadata, StackMetadata};
62use super::Stack;
63use crate::error::StackError;
64
65#[derive(Debug)]
66struct GhStackBranchRef {
67 branch: String,
68 base: String,
69 merged: bool,
70}
71
72impl GhStackBranchRef {
73 fn from_value(value: &Value) -> Option<Self> {
74 Some(Self {
75 branch: value.get("branch")?.as_str()?.to_string(),
76 base: value
77 .get("base")
78 .and_then(|v| v.as_str())
79 .unwrap_or_default()
80 .to_string(),
81 merged: value
82 .get("pullRequest")
83 .and_then(|pr| pr.get("merged"))
84 .and_then(|v| v.as_bool())
85 .unwrap_or(false),
86 })
87 }
88}
89
90#[derive(Debug)]
91struct GhStackEntry {
92 id: String,
93 number: u64,
94 trunk: GhStackBranchRef,
95 branches: Vec<GhStackBranchRef>,
96}
97
98impl GhStackEntry {
99 fn from_value(value: &Value) -> Option<Self> {
100 let trunk = GhStackBranchRef::from_value(value.get("trunk")?)?;
101 let branches = value
102 .get("branches")
103 .and_then(|v| v.as_array())
104 .map(|arr| {
105 arr.iter()
106 .filter_map(GhStackBranchRef::from_value)
107 .collect()
108 })
109 .unwrap_or_default();
110 Some(Self {
111 id: value
112 .get("id")
113 .and_then(|v| v.as_str())
114 .unwrap_or_default()
115 .to_string(),
116 number: value.get("number").and_then(|v| v.as_u64()).unwrap_or(0),
117 trunk,
118 branches,
119 })
120 }
121}
122
123fn parse_stacks(doc: &Value) -> Vec<GhStackEntry> {
126 doc.get("stacks")
127 .and_then(|v| v.as_array())
128 .map(|arr| arr.iter().filter_map(GhStackEntry::from_value).collect())
129 .unwrap_or_default()
130}
131
132const READ_ATTEMPTS: u32 = 3;
135const READ_RETRY_DELAY: Duration = Duration::from_millis(25);
136
137pub(crate) fn canonical_path(repo: &Repository) -> PathBuf {
140 repo.commondir().join("gh-stack")
141}
142
143pub(crate) fn unlinked_files(repo: &Repository) -> Vec<PathBuf> {
147 let canonical = canonical_path(repo);
148 let worktrees_dir = repo.commondir().join("worktrees");
149 let Ok(entries) = std::fs::read_dir(&worktrees_dir) else {
150 return vec![];
151 };
152
153 let mut names: Vec<String> = entries
154 .filter_map(|e| e.ok())
155 .filter(|e| e.path().is_dir())
156 .filter_map(|e| e.file_name().into_string().ok())
157 .collect();
158 names.sort();
159
160 names
161 .into_iter()
162 .filter_map(|name| {
163 let path = worktrees_dir.join(&name).join("gh-stack");
164 if !path_exists_at_all(&path) || is_symlink_resolving_to(&path, &canonical) {
165 None
166 } else {
167 Some(path)
168 }
169 })
170 .collect()
171}
172
173fn path_exists_at_all(path: &Path) -> bool {
174 std::fs::symlink_metadata(path).is_ok()
175}
176
177fn is_symlink_resolving_to(path: &Path, canonical: &Path) -> bool {
181 let Ok(meta) = std::fs::symlink_metadata(path) else {
182 return false;
183 };
184 if !meta.file_type().is_symlink() {
185 return false;
186 }
187 let Ok(target) = std::fs::read_link(path) else {
188 return false;
189 };
190 let Some(parent) = path.parent() else {
191 return false;
192 };
193 normalize_lexically(&parent.join(target)) == normalize_lexically(canonical)
194}
195
196fn normalize_lexically(path: &Path) -> PathBuf {
197 let mut out = PathBuf::new();
198 for component in path.components() {
199 match component {
200 std::path::Component::ParentDir => {
201 out.pop();
202 }
203 std::path::Component::CurDir => {}
204 other => out.push(other.as_os_str()),
205 }
206 }
207 out
208}
209
210pub(crate) fn is_gh_stack_repo(repo: &Repository) -> bool {
213 canonical_path(repo).exists() || !unlinked_files(repo).is_empty()
214}
215
216fn read_doc(path: &Path) -> Result<Option<Vec<GhStackEntry>>, StackError> {
222 let mut last_error: Option<String> = None;
223
224 for attempt in 0..READ_ATTEMPTS {
225 match std::fs::read(path) {
226 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
227 Err(e) => last_error = Some(e.to_string()),
228 Ok(bytes) => match serde_json::from_slice::<Value>(&bytes) {
229 Err(e) => last_error = Some(e.to_string()),
230 Ok(value) => {
231 let version = value
232 .get("schemaVersion")
233 .and_then(|v| v.as_u64())
234 .filter(|&v| v != 0)
235 .unwrap_or(1);
236 if version > 1 {
237 return Err(StackError::GhStackSchemaUnsupported {
238 path: path.to_path_buf(),
239 version,
240 });
241 }
242 return Ok(Some(parse_stacks(&value)));
243 }
244 },
245 }
246 if attempt + 1 < READ_ATTEMPTS {
247 std::thread::sleep(READ_RETRY_DELAY);
248 }
249 }
250
251 log::warn!(
252 "gh-stack: skipping unreadable file {}: {}",
253 path.display(),
254 last_error.unwrap_or_default()
255 );
256 Ok(None)
257}
258
259#[derive(Debug, PartialEq, Eq, Hash)]
262enum StackIdentity {
263 Number(u64),
264 Id(String),
265 TrunkAndFirstBranch(String, String),
266}
267
268fn identity(entry: &GhStackEntry) -> StackIdentity {
269 if entry.number != 0 {
270 StackIdentity::Number(entry.number)
271 } else if !entry.id.is_empty() {
272 StackIdentity::Id(entry.id.clone())
273 } else {
274 let first_branch = entry
275 .branches
276 .first()
277 .map(|b| b.branch.clone())
278 .unwrap_or_default();
279 StackIdentity::TrunkAndFirstBranch(entry.trunk.branch.clone(), first_branch)
280 }
281}
282
283pub(crate) fn read_metadata(repo: &Repository) -> Result<StackMetadata, StackError> {
289 let mut seen: HashSet<StackIdentity> = HashSet::new();
290 let mut kept: Vec<GhStackEntry> = Vec::new();
291
292 let mut sources = vec![canonical_path(repo)];
293 sources.extend(unlinked_files(repo));
294
295 for path in sources {
296 let Some(entries) = read_doc(&path)? else {
297 continue;
298 };
299 for entry in entries {
300 if seen.insert(identity(&entry)) {
301 kept.push(entry);
302 }
303 }
304 }
305
306 let mut trunks: Vec<String> = Vec::new();
307 let mut parents: HashMap<String, BranchMetadata> = HashMap::new();
308 let mut stack_numbers: HashMap<String, u64> = HashMap::new();
309
310 for entry in &kept {
311 if !trunks.contains(&entry.trunk.branch) {
312 trunks.push(entry.trunk.branch.clone());
313 }
314
315 let mut parent = entry.trunk.branch.clone();
319 for branch_ref in &entry.branches {
320 let parent_revision = if branch_ref.base.is_empty() {
321 None
322 } else {
323 Some(branch_ref.base.clone())
324 };
325 parents
329 .entry(branch_ref.branch.clone())
330 .or_insert(BranchMetadata {
331 parent: parent.clone(),
332 parent_revision,
333 merged: branch_ref.merged,
334 });
335 if entry.number != 0 {
336 stack_numbers
337 .entry(branch_ref.branch.clone())
338 .or_insert(entry.number);
339 }
340 parent = branch_ref.branch.clone();
341 }
342 }
343
344 Ok(StackMetadata {
345 trunks,
346 parents,
347 pr_titles: HashMap::new(),
348 stack_numbers,
349 })
350}
351
352pub(crate) fn has_live_branches(repo: &Repository) -> bool {
361 match read_metadata(repo) {
362 Ok(meta) => meta
363 .parents
364 .iter()
365 .any(|(b, m)| !m.merged && crate::resolve::branch_exists(repo, b)),
366 Err(e) => {
367 log::debug!("gh-stack: has_live_branches: read_metadata failed: {e}");
368 false
369 }
370 }
371}
372
373pub(crate) fn enumerate_stacks(repo: &Repository) -> Result<Vec<Stack>, StackError> {
375 Ok(metadata::enumerate(repo, &read_metadata(repo)?))
376}
377
378pub(crate) fn current_stack(
381 repo: &Repository,
382 head_branch: &str,
383) -> Result<Option<Stack>, StackError> {
384 Ok(metadata::current(&read_metadata(repo)?, head_branch))
385}
386
387#[cfg(unix)]
391struct LockGuard(std::fs::File);
392
393#[cfg(unix)]
394impl Drop for LockGuard {
395 fn drop(&mut self) {
396 unsafe {
398 libc::flock(self.0.as_raw_fd(), libc::LOCK_UN);
399 }
400 }
401}
402
403#[cfg(not(unix))]
404struct LockGuard;
405
406const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
407const LOCK_RETRY_DELAY: Duration = Duration::from_millis(100);
408
409#[cfg(unix)]
414fn flock_path(lock_path: PathBuf) -> Result<LockGuard, StackError> {
415 let file = std::fs::OpenOptions::new()
416 .create(true)
417 .write(true)
418 .truncate(false) .open(&lock_path)
420 .map_err(|e| StackError::GhStackWriteFailed {
421 path: lock_path.clone(),
422 message: e.to_string(),
423 })?;
424
425 let deadline = std::time::Instant::now() + LOCK_TIMEOUT;
426 loop {
427 let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
428 if ret == 0 {
429 return Ok(LockGuard(file));
430 }
431 let err = std::io::Error::last_os_error();
432 if err.raw_os_error() != Some(libc::EWOULDBLOCK) || std::time::Instant::now() >= deadline {
433 return Err(StackError::GhStackLocked { path: lock_path });
434 }
435 std::thread::sleep(LOCK_RETRY_DELAY);
436 }
437}
438
439#[cfg(not(unix))]
440fn flock_path(_lock_path: PathBuf) -> Result<LockGuard, StackError> {
441 Ok(LockGuard)
442}
443
444fn lock_operation(repo: &Repository) -> Result<LockGuard, StackError> {
448 flock_path(repo.commondir().join("gh-stack-operation.lock"))
449}
450
451fn lock_canonical(repo: &Repository) -> Result<LockGuard, StackError> {
455 flock_path(repo.commondir().join("gh-stack.lock"))
456}
457
458fn read_raw_doc(path: &Path) -> Result<Option<Value>, StackError> {
464 match std::fs::read(path) {
465 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
466 Err(e) => Err(StackError::GhStackParseFailed {
467 path: path.to_path_buf(),
468 message: e.to_string(),
469 }),
470 Ok(bytes) => {
471 let value: Value =
472 serde_json::from_slice(&bytes).map_err(|e| StackError::GhStackParseFailed {
473 path: path.to_path_buf(),
474 message: e.to_string(),
475 })?;
476 let version = value
477 .get("schemaVersion")
478 .and_then(|v| v.as_u64())
479 .filter(|&v| v != 0)
480 .unwrap_or(1);
481 if version > 1 {
482 return Err(StackError::GhStackSchemaUnsupported {
483 path: path.to_path_buf(),
484 version,
485 });
486 }
487 Ok(Some(value))
488 }
489 }
490}
491
492fn read_raw_stacks(path: &Path) -> Result<Vec<Value>, StackError> {
495 Ok(read_raw_doc(path)?
496 .and_then(|doc| doc.get("stacks").and_then(|v| v.as_array()).cloned())
497 .unwrap_or_default())
498}
499
500fn branch_tip(repo: &Repository, name: &str) -> Result<git2::Oid, StackError> {
508 let branch = repo
509 .find_branch(name, git2::BranchType::Local)
510 .map_err(|e| StackError::GhStackWriteFailed {
511 path: canonical_path(repo),
512 message: format!("branch '{name}' not found: {e}"),
513 })?;
514 branch
515 .get()
516 .target()
517 .ok_or_else(|| StackError::GhStackWriteFailed {
518 path: canonical_path(repo),
519 message: format!("branch '{name}' has no target (unborn?)"),
520 })
521}
522
523fn select_target_index(stacks: &[Value], base_branch: &str) -> Option<usize> {
529 stacks
530 .iter()
531 .position(|stack| {
532 stack
533 .get("branches")
534 .and_then(|b| b.as_array())
535 .and_then(|arr| arr.last())
536 .and_then(|b| b.get("branch"))
537 .and_then(|v| v.as_str())
538 == Some(base_branch)
539 })
540 .or_else(|| {
541 stacks.iter().position(|stack| {
542 let branches_empty = stack
543 .get("branches")
544 .and_then(|b| b.as_array())
545 .map(|arr| arr.is_empty())
546 .unwrap_or(true);
547 branches_empty
548 && stack
549 .get("trunk")
550 .and_then(|t| t.get("branch"))
551 .and_then(|v| v.as_str())
552 == Some(base_branch)
553 })
554 })
555}
556
557fn plan_registered_doc(
563 existing: &[u8],
564 branch: &str,
565 base_branch: &str,
566 base: &str,
567 head: &str,
568 canonical: &Path,
569) -> Result<Vec<u8>, StackError> {
570 let mut doc: Value = if existing.is_empty() {
571 serde_json::json!({ "schemaVersion": 1, "stacks": [] })
572 } else {
573 serde_json::from_slice(existing).map_err(|e| StackError::GhStackParseFailed {
574 path: canonical.to_path_buf(),
575 message: e.to_string(),
576 })?
577 };
578
579 let version = doc
580 .get("schemaVersion")
581 .and_then(|v| v.as_u64())
582 .filter(|&v| v != 0)
583 .unwrap_or(1);
584 if version > 1 {
585 return Err(StackError::GhStackSchemaUnsupported {
586 path: canonical.to_path_buf(),
587 version,
588 });
589 }
590
591 let stacks = doc
592 .get_mut("stacks")
593 .and_then(|v| v.as_array_mut())
594 .ok_or_else(|| StackError::GhStackNoStackForBase {
595 base: base_branch.to_string(),
596 })?;
597
598 let idx = select_target_index(stacks, base_branch).ok_or_else(|| {
599 StackError::GhStackNoStackForBase {
600 base: base_branch.to_string(),
601 }
602 })?;
603
604 let new_entry = serde_json::json!({ "branch": branch, "head": head, "base": base });
606 match stacks[idx]
607 .get_mut("branches")
608 .and_then(|v| v.as_array_mut())
609 {
610 Some(arr) => arr.push(new_entry),
611 None => stacks[idx]["branches"] = serde_json::json!([new_entry]),
612 }
613
614 serde_json::to_vec_pretty(&doc).map_err(|e| StackError::GhStackWriteFailed {
615 path: canonical.to_path_buf(),
616 message: e.to_string(),
617 })
618}
619
620fn write_canonical_atomic(canonical: &Path, bytes: &[u8]) -> Result<(), StackError> {
626 let tmp_path = canonical.with_extension("tmp");
627 std::fs::write(&tmp_path, bytes).map_err(|e| StackError::GhStackWriteFailed {
628 path: tmp_path.clone(),
629 message: e.to_string(),
630 })?;
631
632 #[cfg(unix)]
633 {
634 use std::os::unix::fs::PermissionsExt;
635 std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o644)).map_err(
636 |e| StackError::GhStackWriteFailed {
637 path: tmp_path.clone(),
638 message: e.to_string(),
639 },
640 )?;
641 }
642
643 std::fs::rename(&tmp_path, canonical).map_err(|e| StackError::GhStackWriteFailed {
644 path: canonical.to_path_buf(),
645 message: e.to_string(),
646 })
647}
648
649pub fn register_branch(
668 repo: &Repository,
669 branch: &str,
670 base_branch: &str,
671) -> Result<(), StackError> {
672 let head = branch_tip(repo, branch)?;
673 let base_tip = branch_tip(repo, base_branch)?;
674 let base = repo.merge_base(base_tip, head).unwrap_or(base_tip);
675
676 let canonical = canonical_path(repo);
677 let _operation_lock = lock_operation(repo)?;
678 let _lock = lock_canonical(repo)?;
679
680 let journal = repo.commondir().join("gh-stack-migration");
681 if journal.exists() {
682 return Err(StackError::GhStackMigrationPending { path: journal });
683 }
684
685 let existing = std::fs::read(&canonical).unwrap_or_default();
686 let new_bytes = match plan_registered_doc(
687 &existing,
688 branch,
689 base_branch,
690 &base.to_string(),
691 &head.to_string(),
692 &canonical,
693 ) {
694 Err(StackError::GhStackNoStackForBase { base }) if !unlinked_files(repo).is_empty() => {
701 return Err(StackError::GhStackStackInUnlinkedWorktree { base });
702 }
703 Err(e) => return Err(e),
704 Ok(bytes) => bytes,
705 };
706
707 write_canonical_atomic(&canonical, &new_bytes)
708}
709
710const RECOVERY_FILES: [&str; 2] = ["gh-stack-rebase-state", "gh-stack-modify-state"];
715
716#[derive(Debug, Clone, PartialEq, Eq, Default)]
718pub struct WorktreeGhStackState {
719 pub symlinked: bool,
722 pub legacy_file: bool,
724 pub recovery_files: Vec<&'static str>,
726}
727
728pub(crate) fn worktree_state(repo: &Repository, worktree_name: &str) -> WorktreeGhStackState {
731 let admin_dir = repo.commondir().join("worktrees").join(worktree_name);
732 let file_type = |name: &str| {
733 std::fs::symlink_metadata(admin_dir.join(name))
734 .ok()
735 .map(|m| m.file_type())
736 };
737
738 WorktreeGhStackState {
739 symlinked: ["gh-stack", "gh-stack.lock"]
740 .iter()
741 .any(|name| file_type(name).is_some_and(|t| t.is_symlink())),
742 legacy_file: file_type("gh-stack").is_some_and(|t| t.is_file()),
743 recovery_files: RECOVERY_FILES
744 .into_iter()
745 .filter(|name| file_type(name).is_some())
746 .collect(),
747 }
748}
749
750pub(crate) fn unlink_worktree(
755 repo: &Repository,
756 worktree_name: &str,
757) -> crate::error::Result<Vec<&'static str>> {
758 let admin_dir = repo.commondir().join("worktrees").join(worktree_name);
759 let mut removed = Vec::new();
760 for name in ["gh-stack", "gh-stack.lock"] {
761 let path = admin_dir.join(name);
762 let is_symlink = std::fs::symlink_metadata(&path)
763 .map(|m| m.file_type().is_symlink())
764 .unwrap_or(false);
765 if is_symlink {
766 std::fs::remove_file(&path)?;
767 removed.push(name);
768 }
769 }
770 Ok(removed)
771}
772
773pub(crate) fn readability_errors(repo: &Repository) -> Vec<(PathBuf, StackError)> {
781 let mut sources = vec![canonical_path(repo)];
782 sources.extend(unlinked_files(repo));
783
784 sources
785 .into_iter()
786 .filter(|path| path.exists())
787 .filter_map(|path| match read_raw_stacks(&path) {
788 Ok(_) => None,
789 Err(e) => Some((path, e)),
790 })
791 .collect()
792}
793
794pub(crate) fn divergent_stack_numbers(repo: &Repository) -> Vec<u64> {
805 let mut sources = vec![canonical_path(repo)];
806 sources.extend(unlinked_files(repo));
807
808 let mut signatures_by_number: HashMap<u64, Vec<Vec<String>>> = HashMap::new();
811 for path in &sources {
812 if let Ok(Some(entries)) = read_doc(path) {
813 let mut numbers_in_this_source: HashSet<u64> = HashSet::new();
814 for entry in entries {
815 if entry.number != 0 && numbers_in_this_source.insert(entry.number) {
816 let branches: Vec<String> =
817 entry.branches.iter().map(|b| b.branch.clone()).collect();
818 signatures_by_number
819 .entry(entry.number)
820 .or_default()
821 .push(branches);
822 }
823 }
824 }
825 }
826
827 let mut divergent: Vec<u64> = signatures_by_number
828 .into_iter()
829 .filter(|(_, signatures)| signatures.iter().any(|s| s != &signatures[0]))
830 .map(|(number, _)| number)
831 .collect();
832 divergent.sort_unstable();
833 divergent
834}
835
836#[cfg(test)]
837mod tests {
838 use super::*;
839 use git_workon_fixture::prelude::*;
840
841 #[test]
842 fn reads_linear_stack_from_canonical() {
843 let fixture = FixtureBuilder::new()
844 .bare(true)
845 .default_branch("main")
846 .worktree("main")
847 .gh_stack(None, 12, "main", &["feat-a", "feat-b"])
848 .build()
849 .unwrap();
850 let repo = fixture.repo().unwrap();
851
852 let meta = read_metadata(repo).unwrap();
853 assert_eq!(meta.trunks, vec!["main".to_string()]);
854 assert_eq!(meta.parents["feat-a"].parent, "main");
855 assert_eq!(meta.parents["feat-b"].parent, "feat-a");
856 assert_eq!(meta.stack_numbers["feat-a"], 12);
857 assert_eq!(meta.stack_numbers["feat-b"], 12);
858
859 let stacks = enumerate_stacks(repo).unwrap();
860 assert_eq!(stacks.len(), 1);
861 assert_eq!(stacks[0].number, Some(12));
862 assert_eq!(stacks[0].diffs, vec!["feat-a", "feat-b"]);
863 }
864
865 #[test]
866 fn merged_true_is_read_from_pull_request() {
867 let fixture = FixtureBuilder::new()
868 .bare(true)
869 .default_branch("main")
870 .worktree("main")
871 .branch("feat-a")
872 .branch("feat-b")
873 .raw_gh_stack(
874 None,
875 br#"{"schemaVersion": 1, "stacks": [{"number": 1, "trunk": {"branch": "main", "head": "", "base": ""}, "branches": [{"branch": "feat-a", "head": "", "base": "", "pullRequest": {"number": 1, "merged": true}}, {"branch": "feat-b", "head": "", "base": "", "pullRequest": null}]}]}"#.to_vec(),
876 )
877 .build()
878 .unwrap();
879 let repo = fixture.repo().unwrap();
880
881 let meta = read_metadata(repo).unwrap();
882 assert!(meta.parents["feat-a"].merged);
883 assert!(!meta.parents["feat-b"].merged, "sibling must stay unmerged");
884 }
885
886 #[test]
887 fn missing_pull_request_reads_merged_false() {
888 let fixture = FixtureBuilder::new()
889 .bare(true)
890 .default_branch("main")
891 .worktree("main")
892 .branch("feat-a")
893 .raw_gh_stack(
894 None,
895 br#"{"schemaVersion": 1, "stacks": [{"number": 1, "trunk": {"branch": "main", "head": "", "base": ""}, "branches": [{"branch": "feat-a", "head": "", "base": ""}]}]}"#.to_vec(),
896 )
897 .build()
898 .unwrap();
899 let repo = fixture.repo().unwrap();
900
901 let meta = read_metadata(repo).unwrap();
902 assert!(!meta.parents["feat-a"].merged);
903 }
904
905 #[test]
906 fn null_pull_request_reads_merged_false() {
907 let fixture = FixtureBuilder::new()
908 .bare(true)
909 .default_branch("main")
910 .worktree("main")
911 .branch("feat-a")
912 .raw_gh_stack(
913 None,
914 br#"{"schemaVersion": 1, "stacks": [{"number": 1, "trunk": {"branch": "main", "head": "", "base": ""}, "branches": [{"branch": "feat-a", "head": "", "base": "", "pullRequest": null}]}]}"#.to_vec(),
915 )
916 .build()
917 .unwrap();
918 let repo = fixture.repo().unwrap();
919
920 let meta = read_metadata(repo).unwrap();
921 assert!(!meta.parents["feat-a"].merged);
922 }
923
924 #[test]
925 fn ghost_retained_by_current_stack_and_pruned_by_enumerate() {
926 let fixture = FixtureBuilder::new()
927 .bare(true)
928 .default_branch("main")
929 .worktree("main")
930 .gh_stack(None, 5, "main", &["feat-a"])
931 .gh_stack_ghost_branch(None, 5, "feat-b")
932 .build()
933 .unwrap();
934 let repo = fixture.repo().unwrap();
935
936 let current = current_stack(repo, "feat-a").unwrap().expect("tracked");
940 assert!(current.diffs.contains(&"feat-a".to_string()));
941
942 let enumerated = enumerate_stacks(repo).unwrap();
943 assert_eq!(enumerated.len(), 1);
944 assert!(!enumerated[0].diffs.contains(&"feat-b".to_string()));
945 assert!(enumerated[0].diffs.contains(&"feat-a".to_string()));
946 }
947
948 #[test]
949 fn truncated_file_is_skipped() {
950 let fixture = FixtureBuilder::new()
951 .bare(true)
952 .default_branch("main")
953 .worktree("main")
954 .raw_gh_stack(None, b"{\"schemaVersion\": 1, \"stacks\": [".to_vec())
955 .build()
956 .unwrap();
957 let repo = fixture.repo().unwrap();
958
959 let meta = read_metadata(repo).unwrap();
960 assert!(meta.trunks.is_empty());
961 assert!(meta.parents.is_empty());
962 }
963
964 #[test]
965 fn schema_version_2_is_a_hard_error() {
966 let fixture = FixtureBuilder::new()
967 .bare(true)
968 .default_branch("main")
969 .worktree("main")
970 .raw_gh_stack(None, br#"{"schemaVersion": 2, "stacks": []}"#.to_vec())
971 .build()
972 .unwrap();
973 let repo = fixture.repo().unwrap();
974
975 match read_metadata(repo) {
976 Err(StackError::GhStackSchemaUnsupported { version: 2, .. }) => {}
977 Err(e) => panic!("expected GhStackSchemaUnsupported{{version: 2}}, got {e:?}"),
978 Ok(_) => panic!("expected GhStackSchemaUnsupported{{version: 2}}, got Ok"),
979 }
980 }
981
982 #[test]
983 fn missing_schema_version_defaults_to_1() {
984 let fixture = FixtureBuilder::new()
987 .bare(true)
988 .default_branch("main")
989 .worktree("main")
990 .branch("feat-a")
991 .raw_gh_stack(
992 None,
993 br#"{"stacks": [{"number": 1, "trunk": {"branch": "main", "head": "", "base": ""}, "branches": [{"branch": "feat-a", "head": "", "base": ""}]}]}"#.to_vec(),
994 )
995 .build()
996 .unwrap();
997 let repo = fixture.repo().unwrap();
998
999 let meta = read_metadata(repo).unwrap();
1000 assert_eq!(meta.parents["feat-a"].parent, "main");
1001 assert_eq!(meta.stack_numbers["feat-a"], 1);
1002 }
1003
1004 #[test]
1005 fn schema_version_0_defaults_to_1() {
1006 let fixture = FixtureBuilder::new()
1010 .bare(true)
1011 .default_branch("main")
1012 .worktree("main")
1013 .branch("feat-a")
1014 .raw_gh_stack(
1015 None,
1016 br#"{"schemaVersion": 0, "stacks": [{"number": 1, "trunk": {"branch": "main", "head": "", "base": ""}, "branches": [{"branch": "feat-a", "head": "", "base": ""}]}]}"#.to_vec(),
1017 )
1018 .build()
1019 .unwrap();
1020 let repo = fixture.repo().unwrap();
1021
1022 let meta = read_metadata(repo).unwrap();
1023 assert_eq!(meta.parents["feat-a"].parent, "main");
1024 assert_eq!(meta.stack_numbers["feat-a"], 1);
1025 }
1026
1027 #[test]
1028 fn needs_restack_true_when_base_differs_from_parent_live_tip() {
1029 let fixture = FixtureBuilder::new()
1030 .bare(true)
1031 .default_branch("main")
1032 .worktree("main")
1033 .gh_stack_at(
1034 None,
1035 1,
1036 "main",
1037 &[("feat-a", "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")],
1038 )
1039 .build()
1040 .unwrap();
1041 let repo = fixture.repo().unwrap();
1042
1043 let meta = read_metadata(repo).unwrap();
1044 let entry = meta.parents.get("feat-a").unwrap();
1045 assert_eq!(
1046 entry.parent_revision.as_deref(),
1047 Some("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
1048 );
1049 let main_tip = repo
1050 .find_branch("main", git2::BranchType::Local)
1051 .unwrap()
1052 .get()
1053 .target()
1054 .unwrap();
1055 assert_ne!(
1056 entry.parent_revision.as_deref(),
1057 Some(main_tip.to_string().as_str())
1058 );
1059 }
1060
1061 #[test]
1062 fn degraded_union_pulls_in_unlinked_worktree_file() {
1063 let fixture = FixtureBuilder::new()
1064 .bare(true)
1065 .default_branch("main")
1066 .worktree("main")
1067 .worktree("feat-a")
1068 .gh_stack(Some("feat-a"), 9, "main", &["feat-a"])
1069 .build()
1070 .unwrap();
1071 let repo = fixture.repo().unwrap();
1072
1073 let meta = read_metadata(repo).unwrap();
1074 assert_eq!(meta.parents["feat-a"].parent, "main");
1075 assert_eq!(meta.stack_numbers["feat-a"], 9);
1076 }
1077
1078 #[test]
1079 fn degraded_union_first_wins_on_disagreeing_unlinked_files() {
1080 let fixture = FixtureBuilder::new()
1084 .bare(true)
1085 .default_branch("main")
1086 .worktree("main")
1087 .worktree("feat-a")
1088 .worktree("feat-b")
1089 .gh_stack(Some("feat-a"), 1, "main", &["feat-a"])
1090 .gh_stack(Some("feat-b"), 1, "main", &["feat-b"])
1091 .build()
1092 .unwrap();
1093 let repo = fixture.repo().unwrap();
1094
1095 let meta = read_metadata(repo).unwrap();
1096 assert!(meta.parents.contains_key("feat-a"));
1097 assert!(!meta.parents.contains_key("feat-b"));
1098 }
1099
1100 #[test]
1103 fn two_numbered_stacks_in_one_file_are_not_divergent() {
1104 let fixture = FixtureBuilder::new()
1109 .bare(true)
1110 .default_branch("main")
1111 .branch("other-trunk")
1112 .worktree("main")
1113 .gh_stack(None, 1, "main", &["feat-a"])
1114 .gh_stack(None, 1, "other-trunk", &["feat-b"])
1115 .build()
1116 .unwrap();
1117 let repo = fixture.repo().unwrap();
1118
1119 assert!(divergent_stack_numbers(repo).is_empty());
1120 }
1121
1122 #[test]
1123 fn identical_copy_across_canonical_and_unlinked_is_not_divergent() {
1124 let fixture = FixtureBuilder::new()
1128 .bare(true)
1129 .default_branch("main")
1130 .worktree("main")
1131 .worktree("feat-a")
1132 .gh_stack(None, 4, "main", &["feat-a"])
1133 .gh_stack(Some("feat-a"), 4, "main", &["feat-a"])
1134 .build()
1135 .unwrap();
1136 let repo = fixture.repo().unwrap();
1137
1138 assert!(divergent_stack_numbers(repo).is_empty());
1139 }
1140
1141 #[test]
1142 fn genuinely_differing_copy_across_sources_is_divergent() {
1143 let fixture = FixtureBuilder::new()
1144 .bare(true)
1145 .default_branch("main")
1146 .worktree("main")
1147 .worktree("feat-a")
1148 .branch("feat-b")
1149 .gh_stack(None, 4, "main", &["feat-a"])
1150 .gh_stack(Some("feat-a"), 4, "main", &["feat-b"])
1151 .build()
1152 .unwrap();
1153 let repo = fixture.repo().unwrap();
1154
1155 assert_eq!(divergent_stack_numbers(repo), vec![4]);
1156 }
1157
1158 #[test]
1159 fn branch_spanning_two_stacks_keeps_the_first_stacks_parent_and_number() {
1160 let fixture = FixtureBuilder::new()
1167 .bare(true)
1168 .default_branch("main")
1169 .branch("other-trunk")
1170 .worktree("main")
1171 .gh_stack(None, 1, "main", &["shared"])
1172 .gh_stack(None, 2, "other-trunk", &["shared"])
1173 .build()
1174 .unwrap();
1175 let repo = fixture.repo().unwrap();
1176
1177 let meta = read_metadata(repo).unwrap();
1178 assert_eq!(meta.parents["shared"].parent, "main");
1179 assert_eq!(meta.stack_numbers["shared"], 1);
1180 }
1181
1182 #[test]
1183 fn worktree_state_is_clear_for_a_worktree_with_no_gh_stack_entries() {
1184 let fixture = FixtureBuilder::new()
1185 .bare(true)
1186 .default_branch("main")
1187 .worktree("main")
1188 .worktree("feat-a")
1189 .gh_stack(None, 1, "main", &["feat-a"])
1190 .build()
1191 .unwrap();
1192 let repo = fixture.repo().unwrap();
1193
1194 assert_eq!(
1195 worktree_state(repo, "feat-a"),
1196 WorktreeGhStackState::default()
1197 );
1198 }
1199
1200 #[test]
1201 fn worktree_state_reports_symlinks_and_unlink_removes_only_them() {
1202 let fixture = FixtureBuilder::new()
1203 .bare(true)
1204 .default_branch("main")
1205 .worktree("main")
1206 .worktree("feat-a")
1207 .gh_stack(None, 1, "main", &["feat-a"])
1208 .gh_stack_symlinked("feat-a")
1209 .build()
1210 .unwrap();
1211 let repo = fixture.repo().unwrap();
1212
1213 let state = worktree_state(repo, "feat-a");
1214 assert!(state.symlinked);
1215 assert!(!state.legacy_file);
1216
1217 let removed = unlink_worktree(repo, "feat-a").unwrap();
1218 assert_eq!(removed, ["gh-stack", "gh-stack.lock"]);
1219 assert_eq!(
1220 worktree_state(repo, "feat-a"),
1221 WorktreeGhStackState::default()
1222 );
1223 assert!(canonical_path(repo).is_file());
1225 }
1226
1227 #[test]
1228 fn unlink_worktree_leaves_a_real_legacy_file_alone() {
1229 let fixture = FixtureBuilder::new()
1230 .bare(true)
1231 .default_branch("main")
1232 .worktree("main")
1233 .worktree("feat-a")
1234 .gh_stack(Some("feat-a"), 2, "main", &["feat-a"])
1235 .build()
1236 .unwrap();
1237 let repo = fixture.repo().unwrap();
1238
1239 let state = worktree_state(repo, "feat-a");
1240 assert!(state.legacy_file);
1241 assert!(!state.symlinked);
1242
1243 assert!(unlink_worktree(repo, "feat-a").unwrap().is_empty());
1244 let file = repo.commondir().join("worktrees/feat-a/gh-stack");
1245 assert!(file.is_file());
1246 }
1247
1248 #[test]
1249 fn worktree_state_reports_recovery_files() {
1250 let fixture = FixtureBuilder::new()
1251 .bare(true)
1252 .default_branch("main")
1253 .worktree("main")
1254 .worktree("feat-a")
1255 .gh_stack_recovery_file("feat-a", "gh-stack-rebase-state")
1256 .build()
1257 .unwrap();
1258 let repo = fixture.repo().unwrap();
1259
1260 assert_eq!(
1261 worktree_state(repo, "feat-a").recovery_files,
1262 ["gh-stack-rebase-state"]
1263 );
1264 }
1265
1266 #[test]
1269 fn register_branch_appends_onto_a_trunk_with_no_branches_yet() {
1270 let fixture = FixtureBuilder::new()
1271 .bare(true)
1272 .default_branch("main")
1273 .worktree("main")
1274 .branch("feat-a")
1275 .gh_stack(None, 1, "main", &[])
1276 .build()
1277 .unwrap();
1278 let repo = fixture.repo().unwrap();
1279
1280 register_branch(repo, "feat-a", "main").unwrap();
1281
1282 repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-a", 0));
1283 let head_oid = repo
1284 .find_branch("feat-a", git2::BranchType::Local)
1285 .unwrap()
1286 .get()
1287 .target()
1288 .unwrap();
1289 repo.assert(predicate::repo::gh_stack_branch_base(
1290 None,
1291 "feat-a",
1292 head_oid.to_string(),
1293 ));
1294 }
1295
1296 #[test]
1297 fn register_branch_appends_onto_the_top_of_an_existing_stack() {
1298 let fixture = FixtureBuilder::new()
1299 .bare(true)
1300 .default_branch("main")
1301 .worktree("main")
1302 .gh_stack(None, 1, "main", &["feat-a"])
1303 .branch("feat-b")
1304 .build()
1305 .unwrap();
1306 let repo = fixture.repo().unwrap();
1307
1308 register_branch(repo, "feat-b", "feat-a").unwrap();
1309
1310 repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-a", 0));
1311 repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-b", 1));
1312 }
1313
1314 #[test]
1315 fn register_branch_preserves_id_and_pull_request_on_untouched_entries() {
1316 let fixture = FixtureBuilder::new()
1320 .bare(true)
1321 .default_branch("main")
1322 .worktree("main")
1323 .branch("feat-a")
1324 .branch("feat-b")
1325 .raw_gh_stack(
1326 None,
1327 br#"{
1328 "schemaVersion": 1,
1329 "stacks": [{
1330 "id": "stack-abc",
1331 "number": 3,
1332 "trunk": { "branch": "main", "head": "", "base": "" },
1333 "branches": [{
1334 "branch": "feat-a",
1335 "head": "0000000000000000000000000000000000000a",
1336 "base": "0000000000000000000000000000000000000b",
1337 "pullRequest": { "number": 42, "id": "PR_1", "merged": false }
1338 }]
1339 }]
1340 }"#
1341 .to_vec(),
1342 )
1343 .build()
1344 .unwrap();
1345 let repo = fixture.repo().unwrap();
1346
1347 register_branch(repo, "feat-b", "feat-a").unwrap();
1348
1349 repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-a", 0));
1350 repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-b", 1));
1351 repo.assert(predicate::repo::gh_stack_preserves(
1352 None,
1353 "/stacks/0/id",
1354 "stack-abc",
1355 ));
1356 repo.assert(predicate::repo::gh_stack_preserves(
1357 None,
1358 "/stacks/0/branches/0/pullRequest/number",
1359 "42",
1360 ));
1361 }
1362
1363 #[test]
1364 fn register_branch_surfaces_parse_failed_for_truncated_canonical() {
1365 let fixture = FixtureBuilder::new()
1370 .bare(true)
1371 .default_branch("main")
1372 .worktree("main")
1373 .branch("feat-a")
1374 .branch("feat-b")
1375 .gh_stack(None, 1, "main", &["feat-a"])
1376 .raw_gh_stack(None, b"{\"schemaVersion\": 1, \"stacks\": [".to_vec())
1377 .build()
1378 .unwrap();
1379 let repo = fixture.repo().unwrap();
1380
1381 match register_branch(repo, "feat-b", "feat-a") {
1382 Err(StackError::GhStackParseFailed { .. }) => {}
1383 other => panic!("expected GhStackParseFailed, got {other:?}"),
1384 }
1385 }
1386
1387 #[test]
1388 fn register_branch_errors_when_no_stack_ends_at_base() {
1389 let fixture = FixtureBuilder::new()
1393 .bare(true)
1394 .default_branch("main")
1395 .worktree("main")
1396 .branch("feat-a")
1397 .branch("feat-b")
1398 .gh_stack(None, 1, "main", &["feat-a"])
1399 .build()
1400 .unwrap();
1401 let repo = fixture.repo().unwrap();
1402
1403 match register_branch(repo, "feat-b", "main") {
1404 Err(StackError::GhStackNoStackForBase { base }) => {
1405 assert_eq!(base, "main");
1406 }
1407 other => panic!("expected GhStackNoStackForBase, got {other:?}"),
1408 }
1409 }
1410
1411 #[test]
1412 fn register_branch_points_at_doctor_fix_when_stack_is_unlinked_only() {
1413 let fixture = FixtureBuilder::new()
1420 .bare(true)
1421 .default_branch("main")
1422 .worktree("main")
1423 .worktree("feat-a")
1424 .branch("feat-b")
1425 .gh_stack(Some("feat-a"), 1, "main", &["feat-a"])
1426 .build()
1427 .unwrap();
1428 let repo = fixture.repo().unwrap();
1429
1430 let meta = read_metadata(repo).unwrap();
1433 assert_eq!(meta.parents["feat-a"].parent, "main");
1434
1435 match register_branch(repo, "feat-b", "feat-a") {
1436 Err(StackError::GhStackStackInUnlinkedWorktree { base }) => {
1437 assert_eq!(base, "feat-a");
1438 }
1439 other => panic!("expected GhStackStackInUnlinkedWorktree, got {other:?}"),
1440 }
1441 }
1442
1443 #[cfg(unix)]
1444 #[test]
1445 fn register_branch_times_out_when_operation_lock_is_held() {
1446 let fixture = FixtureBuilder::new()
1447 .bare(true)
1448 .default_branch("main")
1449 .worktree("main")
1450 .branch("feat-a")
1451 .gh_stack(None, 1, "main", &[])
1452 .gh_stack_operation_lock_held()
1453 .build()
1454 .unwrap();
1455 let repo = fixture.repo().unwrap();
1456
1457 match register_branch(repo, "feat-a", "main") {
1458 Err(StackError::GhStackLocked { path }) => {
1459 assert_eq!(path, repo.commondir().join("gh-stack-operation.lock"));
1460 }
1461 other => panic!("expected GhStackLocked, got {other:?}"),
1462 }
1463 repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-a", 0).not());
1464 }
1465
1466 #[test]
1467 fn register_branch_refuses_while_migration_journal_exists() {
1468 let fixture = FixtureBuilder::new()
1469 .bare(true)
1470 .default_branch("main")
1471 .worktree("main")
1472 .branch("feat-a")
1473 .gh_stack(None, 1, "main", &[])
1474 .gh_stack_migration_journal()
1475 .build()
1476 .unwrap();
1477 let repo = fixture.repo().unwrap();
1478
1479 match register_branch(repo, "feat-a", "main") {
1480 Err(StackError::GhStackMigrationPending { path }) => {
1481 assert_eq!(path, repo.commondir().join("gh-stack-migration"));
1482 }
1483 other => panic!("expected GhStackMigrationPending, got {other:?}"),
1484 }
1485 repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-a", 0).not());
1486 }
1487}