1use std::collections::{HashMap, HashSet};
50use std::path::{Path, PathBuf};
51use std::time::Duration;
52
53use git2::Repository;
54use serde_json::Value;
55
56#[cfg(unix)]
57use std::os::unix::io::AsRawFd;
58
59use super::metadata::{self, BranchMetadata, StackMetadata};
60use super::Stack;
61use crate::error::StackError;
62
63#[derive(Debug)]
71struct GhStackBranchRef {
72 branch: String,
73 base: String,
74}
75
76impl GhStackBranchRef {
77 fn from_value(value: &Value) -> Option<Self> {
78 Some(Self {
79 branch: value.get("branch")?.as_str()?.to_string(),
80 base: value
81 .get("base")
82 .and_then(|v| v.as_str())
83 .unwrap_or_default()
84 .to_string(),
85 })
86 }
87}
88
89#[derive(Debug)]
90struct GhStackEntry {
91 id: String,
92 number: u64,
93 trunk: GhStackBranchRef,
94 branches: Vec<GhStackBranchRef>,
95}
96
97impl GhStackEntry {
98 fn from_value(value: &Value) -> Option<Self> {
99 let trunk = GhStackBranchRef::from_value(value.get("trunk")?)?;
100 let branches = value
101 .get("branches")
102 .and_then(|v| v.as_array())
103 .map(|arr| {
104 arr.iter()
105 .filter_map(GhStackBranchRef::from_value)
106 .collect()
107 })
108 .unwrap_or_default();
109 Some(Self {
110 id: value
111 .get("id")
112 .and_then(|v| v.as_str())
113 .unwrap_or_default()
114 .to_string(),
115 number: value.get("number").and_then(|v| v.as_u64()).unwrap_or(0),
116 trunk,
117 branches,
118 })
119 }
120}
121
122fn parse_stacks(doc: &Value) -> Vec<GhStackEntry> {
125 doc.get("stacks")
126 .and_then(|v| v.as_array())
127 .map(|arr| arr.iter().filter_map(GhStackEntry::from_value).collect())
128 .unwrap_or_default()
129}
130
131const READ_ATTEMPTS: u32 = 3;
134const READ_RETRY_DELAY: Duration = Duration::from_millis(25);
135
136pub(crate) fn canonical_path(repo: &Repository) -> PathBuf {
139 repo.commondir().join("gh-stack")
140}
141
142pub(crate) fn unlinked_files(repo: &Repository) -> Vec<PathBuf> {
146 let canonical = canonical_path(repo);
147 let worktrees_dir = repo.commondir().join("worktrees");
148 let Ok(entries) = std::fs::read_dir(&worktrees_dir) else {
149 return vec![];
150 };
151
152 let mut names: Vec<String> = entries
153 .filter_map(|e| e.ok())
154 .filter(|e| e.path().is_dir())
155 .filter_map(|e| e.file_name().into_string().ok())
156 .collect();
157 names.sort();
158
159 names
160 .into_iter()
161 .filter_map(|name| {
162 let path = worktrees_dir.join(&name).join("gh-stack");
163 if !path_exists_at_all(&path) || is_symlink_resolving_to(&path, &canonical) {
164 None
165 } else {
166 Some(path)
167 }
168 })
169 .collect()
170}
171
172fn path_exists_at_all(path: &Path) -> bool {
173 std::fs::symlink_metadata(path).is_ok()
174}
175
176fn is_symlink_resolving_to(path: &Path, canonical: &Path) -> bool {
180 let Ok(meta) = std::fs::symlink_metadata(path) else {
181 return false;
182 };
183 if !meta.file_type().is_symlink() {
184 return false;
185 }
186 let Ok(target) = std::fs::read_link(path) else {
187 return false;
188 };
189 let Some(parent) = path.parent() else {
190 return false;
191 };
192 normalize_lexically(&parent.join(target)) == normalize_lexically(canonical)
193}
194
195fn normalize_lexically(path: &Path) -> PathBuf {
196 let mut out = PathBuf::new();
197 for component in path.components() {
198 match component {
199 std::path::Component::ParentDir => {
200 out.pop();
201 }
202 std::path::Component::CurDir => {}
203 other => out.push(other.as_os_str()),
204 }
205 }
206 out
207}
208
209pub(crate) fn is_gh_stack_repo(repo: &Repository) -> bool {
212 canonical_path(repo).exists() || !unlinked_files(repo).is_empty()
213}
214
215fn read_doc(path: &Path) -> Result<Option<Vec<GhStackEntry>>, StackError> {
221 let mut last_error: Option<String> = None;
222
223 for attempt in 0..READ_ATTEMPTS {
224 match std::fs::read(path) {
225 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
226 Err(e) => last_error = Some(e.to_string()),
227 Ok(bytes) => match serde_json::from_slice::<Value>(&bytes) {
228 Err(e) => last_error = Some(e.to_string()),
229 Ok(value) => {
230 let version = value
231 .get("schemaVersion")
232 .and_then(|v| v.as_u64())
233 .filter(|&v| v != 0)
234 .unwrap_or(1);
235 if version > 1 {
236 return Err(StackError::GhStackSchemaUnsupported {
237 path: path.to_path_buf(),
238 version,
239 });
240 }
241 return Ok(Some(parse_stacks(&value)));
242 }
243 },
244 }
245 if attempt + 1 < READ_ATTEMPTS {
246 std::thread::sleep(READ_RETRY_DELAY);
247 }
248 }
249
250 log::warn!(
251 "gh-stack: skipping unreadable file {}: {}",
252 path.display(),
253 last_error.unwrap_or_default()
254 );
255 Ok(None)
256}
257
258#[derive(Debug, PartialEq, Eq, Hash)]
261enum StackIdentity {
262 Number(u64),
263 Id(String),
264 TrunkAndFirstBranch(String, String),
265}
266
267fn identity(entry: &GhStackEntry) -> StackIdentity {
268 if entry.number != 0 {
269 StackIdentity::Number(entry.number)
270 } else if !entry.id.is_empty() {
271 StackIdentity::Id(entry.id.clone())
272 } else {
273 let first_branch = entry
274 .branches
275 .first()
276 .map(|b| b.branch.clone())
277 .unwrap_or_default();
278 StackIdentity::TrunkAndFirstBranch(entry.trunk.branch.clone(), first_branch)
279 }
280}
281
282pub(crate) fn read_metadata(repo: &Repository) -> Result<StackMetadata, StackError> {
288 let mut seen: HashSet<StackIdentity> = HashSet::new();
289 let mut kept: Vec<GhStackEntry> = Vec::new();
290
291 let mut sources = vec![canonical_path(repo)];
292 sources.extend(unlinked_files(repo));
293
294 for path in sources {
295 let Some(entries) = read_doc(&path)? else {
296 continue;
297 };
298 for entry in entries {
299 if seen.insert(identity(&entry)) {
300 kept.push(entry);
301 }
302 }
303 }
304
305 let mut trunks: Vec<String> = Vec::new();
306 let mut parents: HashMap<String, BranchMetadata> = HashMap::new();
307 let mut stack_numbers: HashMap<String, u64> = HashMap::new();
308
309 for entry in &kept {
310 if !trunks.contains(&entry.trunk.branch) {
311 trunks.push(entry.trunk.branch.clone());
312 }
313
314 let mut parent = entry.trunk.branch.clone();
318 for branch_ref in &entry.branches {
319 let parent_revision = if branch_ref.base.is_empty() {
320 None
321 } else {
322 Some(branch_ref.base.clone())
323 };
324 parents
328 .entry(branch_ref.branch.clone())
329 .or_insert(BranchMetadata {
330 parent: parent.clone(),
331 parent_revision,
332 });
333 if entry.number != 0 {
334 stack_numbers
335 .entry(branch_ref.branch.clone())
336 .or_insert(entry.number);
337 }
338 parent = branch_ref.branch.clone();
339 }
340 }
341
342 Ok(StackMetadata {
343 trunks,
344 parents,
345 pr_titles: HashMap::new(),
346 stack_numbers,
347 })
348}
349
350pub(crate) fn enumerate_stacks(repo: &Repository) -> Result<Vec<Stack>, StackError> {
352 Ok(metadata::enumerate(repo, &read_metadata(repo)?))
353}
354
355pub(crate) fn current_stack(
358 repo: &Repository,
359 head_branch: &str,
360) -> Result<Option<Stack>, StackError> {
361 Ok(metadata::current(&read_metadata(repo)?, head_branch))
362}
363
364#[cfg(unix)]
368struct LockGuard(std::fs::File);
369
370#[cfg(unix)]
371impl Drop for LockGuard {
372 fn drop(&mut self) {
373 unsafe {
375 libc::flock(self.0.as_raw_fd(), libc::LOCK_UN);
376 }
377 }
378}
379
380#[cfg(not(unix))]
381struct LockGuard;
382
383const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
384const LOCK_RETRY_DELAY: Duration = Duration::from_millis(100);
385
386#[cfg(unix)]
391fn lock_canonical(repo: &Repository) -> Result<LockGuard, StackError> {
392 let lock_path = repo.commondir().join("gh-stack.lock");
393 let file = std::fs::OpenOptions::new()
394 .create(true)
395 .write(true)
396 .truncate(false) .open(&lock_path)
398 .map_err(|e| StackError::GhStackWriteFailed {
399 path: lock_path.clone(),
400 message: e.to_string(),
401 })?;
402
403 let deadline = std::time::Instant::now() + LOCK_TIMEOUT;
404 loop {
405 let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
406 if ret == 0 {
407 return Ok(LockGuard(file));
408 }
409 let err = std::io::Error::last_os_error();
410 if err.raw_os_error() != Some(libc::EWOULDBLOCK) || std::time::Instant::now() >= deadline {
411 return Err(StackError::GhStackLocked { path: lock_path });
412 }
413 std::thread::sleep(LOCK_RETRY_DELAY);
414 }
415}
416
417#[cfg(not(unix))]
418fn lock_canonical(_repo: &Repository) -> Result<LockGuard, StackError> {
419 Ok(LockGuard)
420}
421
422#[cfg(unix)]
423fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
424 std::os::unix::fs::symlink(target, link)
425}
426
427#[cfg(windows)]
428fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
429 std::os::windows::fs::symlink_file(target, link)
430}
431
432fn plant_link(admin_dir: &Path, filename: &str) -> Result<(), StackError> {
436 let link_path = admin_dir.join(filename);
437 let relative_target = Path::new("..").join("..").join(filename);
438
439 match std::fs::symlink_metadata(&link_path) {
440 Ok(meta) if meta.file_type().is_symlink() => {
441 if std::fs::read_link(&link_path).ok().as_deref() == Some(relative_target.as_path()) {
442 return Ok(()); }
444 std::fs::remove_file(&link_path).map_err(|e| StackError::GhStackLinkFailed {
445 path: link_path.clone(),
446 message: e.to_string(),
447 })?;
448 create_symlink(&relative_target, &link_path).map_err(|e| {
449 StackError::GhStackLinkFailed {
450 path: link_path,
451 message: e.to_string(),
452 }
453 })
454 }
455 Ok(_) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
457 create_symlink(&relative_target, &link_path).map_err(|e| {
458 StackError::GhStackLinkFailed {
459 path: link_path,
460 message: e.to_string(),
461 }
462 })
463 }
464 Err(e) => Err(StackError::GhStackLinkFailed {
465 path: link_path,
466 message: e.to_string(),
467 }),
468 }
469}
470
471pub(crate) fn link_worktree(repo: &Repository, worktree_name: &str) -> Result<(), StackError> {
479 let admin_dir = repo.commondir().join("worktrees").join(worktree_name);
480 plant_link(&admin_dir, "gh-stack")?;
481 plant_link(&admin_dir, "gh-stack.lock")?;
482 Ok(())
483}
484
485fn raw_identity(entry: &Value) -> StackIdentity {
490 let number = entry.get("number").and_then(|v| v.as_u64()).unwrap_or(0);
491 if number != 0 {
492 return StackIdentity::Number(number);
493 }
494 let id = entry.get("id").and_then(|v| v.as_str()).unwrap_or_default();
495 if !id.is_empty() {
496 return StackIdentity::Id(id.to_string());
497 }
498 let trunk = entry
499 .get("trunk")
500 .and_then(|t| t.get("branch"))
501 .and_then(|v| v.as_str())
502 .unwrap_or_default()
503 .to_string();
504 let first_branch = entry
505 .get("branches")
506 .and_then(|b| b.as_array())
507 .and_then(|arr| arr.first())
508 .and_then(|b| b.get("branch"))
509 .and_then(|v| v.as_str())
510 .unwrap_or_default()
511 .to_string();
512 StackIdentity::TrunkAndFirstBranch(trunk, first_branch)
513}
514
515fn read_raw_doc(path: &Path) -> Result<Option<Value>, StackError> {
521 match std::fs::read(path) {
522 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
523 Err(e) => Err(StackError::GhStackParseFailed {
524 path: path.to_path_buf(),
525 message: e.to_string(),
526 }),
527 Ok(bytes) => {
528 let value: Value =
529 serde_json::from_slice(&bytes).map_err(|e| StackError::GhStackParseFailed {
530 path: path.to_path_buf(),
531 message: e.to_string(),
532 })?;
533 let version = value
534 .get("schemaVersion")
535 .and_then(|v| v.as_u64())
536 .filter(|&v| v != 0)
537 .unwrap_or(1);
538 if version > 1 {
539 return Err(StackError::GhStackSchemaUnsupported {
540 path: path.to_path_buf(),
541 version,
542 });
543 }
544 Ok(Some(value))
545 }
546 }
547}
548
549fn read_raw_stacks(path: &Path) -> Result<Vec<Value>, StackError> {
552 Ok(read_raw_doc(path)?
553 .and_then(|doc| doc.get("stacks").and_then(|v| v.as_array()).cloned())
554 .unwrap_or_default())
555}
556
557pub(crate) fn migrate_worktree(repo: &Repository, worktree_name: &str) -> Result<(), StackError> {
569 let admin_dir = repo.commondir().join("worktrees").join(worktree_name);
570 let worktree_file = admin_dir.join("gh-stack");
571
572 let is_regular_file = matches!(
573 std::fs::symlink_metadata(&worktree_file),
574 Ok(meta) if !meta.file_type().is_symlink()
575 );
576 if !is_regular_file {
577 remove_stale_lock_file(&admin_dir)?;
578 return link_worktree(repo, worktree_name);
579 }
580
581 let _lock = lock_canonical(repo)?;
582
583 let canonical = canonical_path(repo);
584 let canonical_doc = read_raw_doc(&canonical)?;
585
586 let mut doc = match &canonical_doc {
591 Some(v) => v.clone(),
592 None => read_raw_doc(&worktree_file)?
593 .unwrap_or_else(|| serde_json::json!({ "schemaVersion": 1, "stacks": [] })),
594 };
595
596 let mut merged: Vec<Value> = canonical_doc
597 .as_ref()
598 .and_then(|v| v.get("stacks"))
599 .and_then(|v| v.as_array())
600 .cloned()
601 .unwrap_or_default();
602 let mut seen: HashSet<StackIdentity> = merged.iter().map(raw_identity).collect();
603 for entry in read_raw_stacks(&worktree_file)? {
604 if seen.insert(raw_identity(&entry)) {
605 merged.push(entry);
606 }
607 }
608
609 doc["schemaVersion"] = serde_json::json!(1);
610 doc["stacks"] = serde_json::Value::Array(merged);
611
612 let bytes = serde_json::to_vec_pretty(&doc).map_err(|e| StackError::GhStackWriteFailed {
615 path: canonical.clone(),
616 message: e.to_string(),
617 })?;
618 serde_json::from_slice::<Value>(&bytes).map_err(|e| StackError::GhStackParseFailed {
619 path: canonical.clone(),
620 message: e.to_string(),
621 })?;
622
623 let tmp_path = canonical.with_extension("tmp");
624 std::fs::write(&tmp_path, &bytes).map_err(|e| StackError::GhStackWriteFailed {
625 path: tmp_path.clone(),
626 message: e.to_string(),
627 })?;
628 std::fs::rename(&tmp_path, &canonical).map_err(|e| StackError::GhStackWriteFailed {
629 path: canonical.clone(),
630 message: e.to_string(),
631 })?;
632
633 read_raw_stacks(&canonical)?;
639
640 let bak_path = next_available_backup_path(&admin_dir);
641 std::fs::rename(&worktree_file, &bak_path).map_err(|e| StackError::GhStackWriteFailed {
642 path: worktree_file.clone(),
643 message: e.to_string(),
644 })?;
645
646 remove_stale_lock_file(&admin_dir)?;
647 link_worktree(repo, worktree_name)
648}
649
650fn remove_stale_lock_file(admin_dir: &Path) -> Result<(), StackError> {
662 let lock_path = admin_dir.join("gh-stack.lock");
663 let is_regular_file = matches!(
664 std::fs::symlink_metadata(&lock_path),
665 Ok(meta) if !meta.file_type().is_symlink()
666 );
667 if is_regular_file {
668 std::fs::remove_file(&lock_path).map_err(|e| StackError::GhStackWriteFailed {
669 path: lock_path,
670 message: e.to_string(),
671 })?;
672 }
673 Ok(())
674}
675
676fn next_available_backup_path(admin_dir: &Path) -> PathBuf {
682 let base = admin_dir.join("gh-stack.bak");
683 if std::fs::symlink_metadata(&base).is_err() {
684 return base;
685 }
686 (1u32..)
687 .map(|n| admin_dir.join(format!("gh-stack.bak.{n}")))
688 .find(|candidate| std::fs::symlink_metadata(candidate).is_err())
689 .expect("u32 backup suffixes are effectively inexhaustible")
690}
691
692fn branch_tip(repo: &Repository, name: &str) -> Result<git2::Oid, StackError> {
700 let branch = repo
701 .find_branch(name, git2::BranchType::Local)
702 .map_err(|e| StackError::GhStackWriteFailed {
703 path: canonical_path(repo),
704 message: format!("branch '{name}' not found: {e}"),
705 })?;
706 branch
707 .get()
708 .target()
709 .ok_or_else(|| StackError::GhStackWriteFailed {
710 path: canonical_path(repo),
711 message: format!("branch '{name}' has no target (unborn?)"),
712 })
713}
714
715fn select_target_index(stacks: &[Value], base_branch: &str) -> Option<usize> {
721 stacks
722 .iter()
723 .position(|stack| {
724 stack
725 .get("branches")
726 .and_then(|b| b.as_array())
727 .and_then(|arr| arr.last())
728 .and_then(|b| b.get("branch"))
729 .and_then(|v| v.as_str())
730 == Some(base_branch)
731 })
732 .or_else(|| {
733 stacks.iter().position(|stack| {
734 let branches_empty = stack
735 .get("branches")
736 .and_then(|b| b.as_array())
737 .map(|arr| arr.is_empty())
738 .unwrap_or(true);
739 branches_empty
740 && stack
741 .get("trunk")
742 .and_then(|t| t.get("branch"))
743 .and_then(|v| v.as_str())
744 == Some(base_branch)
745 })
746 })
747}
748
749fn plan_registered_doc(
755 existing: &[u8],
756 branch: &str,
757 base_branch: &str,
758 base: &str,
759 head: &str,
760 canonical: &Path,
761) -> Result<Vec<u8>, StackError> {
762 let mut doc: Value = if existing.is_empty() {
763 serde_json::json!({ "schemaVersion": 1, "stacks": [] })
764 } else {
765 serde_json::from_slice(existing).map_err(|e| StackError::GhStackParseFailed {
766 path: canonical.to_path_buf(),
767 message: e.to_string(),
768 })?
769 };
770
771 let version = doc
772 .get("schemaVersion")
773 .and_then(|v| v.as_u64())
774 .filter(|&v| v != 0)
775 .unwrap_or(1);
776 if version > 1 {
777 return Err(StackError::GhStackSchemaUnsupported {
778 path: canonical.to_path_buf(),
779 version,
780 });
781 }
782
783 let stacks = doc
784 .get_mut("stacks")
785 .and_then(|v| v.as_array_mut())
786 .ok_or_else(|| StackError::GhStackNoStackForBase {
787 base: base_branch.to_string(),
788 })?;
789
790 let idx = select_target_index(stacks, base_branch).ok_or_else(|| {
791 StackError::GhStackNoStackForBase {
792 base: base_branch.to_string(),
793 }
794 })?;
795
796 let new_entry = serde_json::json!({ "branch": branch, "head": head, "base": base });
798 match stacks[idx]
799 .get_mut("branches")
800 .and_then(|v| v.as_array_mut())
801 {
802 Some(arr) => arr.push(new_entry),
803 None => stacks[idx]["branches"] = serde_json::json!([new_entry]),
804 }
805
806 serde_json::to_vec_pretty(&doc).map_err(|e| StackError::GhStackWriteFailed {
807 path: canonical.to_path_buf(),
808 message: e.to_string(),
809 })
810}
811
812fn write_canonical_atomic(canonical: &Path, bytes: &[u8]) -> Result<(), StackError> {
818 let tmp_path = canonical.with_extension("tmp");
819 std::fs::write(&tmp_path, bytes).map_err(|e| StackError::GhStackWriteFailed {
820 path: tmp_path.clone(),
821 message: e.to_string(),
822 })?;
823
824 #[cfg(unix)]
825 {
826 use std::os::unix::fs::PermissionsExt;
827 std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o644)).map_err(
828 |e| StackError::GhStackWriteFailed {
829 path: tmp_path.clone(),
830 message: e.to_string(),
831 },
832 )?;
833 }
834
835 std::fs::rename(&tmp_path, canonical).map_err(|e| StackError::GhStackWriteFailed {
836 path: canonical.to_path_buf(),
837 message: e.to_string(),
838 })
839}
840
841pub fn register_branch(
857 repo: &Repository,
858 branch: &str,
859 base_branch: &str,
860) -> Result<(), StackError> {
861 let head = branch_tip(repo, branch)?;
862 let base_tip = branch_tip(repo, base_branch)?;
863 let base = repo.merge_base(base_tip, head).unwrap_or(base_tip);
864
865 let canonical = canonical_path(repo);
866 let _lock = lock_canonical(repo)?;
867
868 let existing = std::fs::read(&canonical).unwrap_or_default();
869 let new_bytes = match plan_registered_doc(
870 &existing,
871 branch,
872 base_branch,
873 &base.to_string(),
874 &head.to_string(),
875 &canonical,
876 ) {
877 Err(StackError::GhStackNoStackForBase { base }) if !unlinked_files(repo).is_empty() => {
884 return Err(StackError::GhStackStackInUnlinkedWorktree { base });
885 }
886 Err(e) => return Err(e),
887 Ok(bytes) => bytes,
888 };
889
890 write_canonical_atomic(&canonical, &new_bytes)
891}
892
893#[derive(Debug, Clone, Copy, PartialEq, Eq)]
897pub enum LinkStatus {
898 Linked,
900 NotLinked { holds_file: bool },
905}
906
907fn link_status_for_path(path: &Path, expected_target: &Path) -> LinkStatus {
915 match std::fs::symlink_metadata(path) {
916 Err(_) => LinkStatus::NotLinked { holds_file: false },
917 Ok(meta) if meta.file_type().is_symlink() => {
918 if is_symlink_resolving_to(path, expected_target) {
919 LinkStatus::Linked
920 } else {
921 LinkStatus::NotLinked { holds_file: false }
922 }
923 }
924 Ok(_) => LinkStatus::NotLinked { holds_file: true },
925 }
926}
927
928pub(crate) fn worktree_link_status(repo: &Repository, worktree_name: &str) -> LinkStatus {
941 let admin_dir = repo.commondir().join("worktrees").join(worktree_name);
942 let canonical = canonical_path(repo);
943 let canonical_lock = repo.commondir().join("gh-stack.lock");
944
945 let gh_stack_status = link_status_for_path(&admin_dir.join("gh-stack"), &canonical);
946 if matches!(gh_stack_status, LinkStatus::NotLinked { .. }) {
947 return gh_stack_status;
948 }
949
950 match link_status_for_path(&admin_dir.join("gh-stack.lock"), &canonical_lock) {
951 LinkStatus::Linked => LinkStatus::Linked,
952 LinkStatus::NotLinked { .. } => LinkStatus::NotLinked { holds_file: false },
953 }
954}
955
956pub(crate) fn readability_errors(repo: &Repository) -> Vec<(PathBuf, StackError)> {
964 let mut sources = vec![canonical_path(repo)];
965 sources.extend(unlinked_files(repo));
966
967 sources
968 .into_iter()
969 .filter(|path| path.exists())
970 .filter_map(|path| match read_raw_stacks(&path) {
971 Ok(_) => None,
972 Err(e) => Some((path, e)),
973 })
974 .collect()
975}
976
977pub(crate) fn divergent_stack_numbers(repo: &Repository) -> Vec<u64> {
988 let mut sources = vec![canonical_path(repo)];
989 sources.extend(unlinked_files(repo));
990
991 let mut signatures_by_number: HashMap<u64, Vec<Vec<String>>> = HashMap::new();
994 for path in &sources {
995 if let Ok(Some(entries)) = read_doc(path) {
996 let mut numbers_in_this_source: HashSet<u64> = HashSet::new();
997 for entry in entries {
998 if entry.number != 0 && numbers_in_this_source.insert(entry.number) {
999 let branches: Vec<String> =
1000 entry.branches.iter().map(|b| b.branch.clone()).collect();
1001 signatures_by_number
1002 .entry(entry.number)
1003 .or_default()
1004 .push(branches);
1005 }
1006 }
1007 }
1008 }
1009
1010 let mut divergent: Vec<u64> = signatures_by_number
1011 .into_iter()
1012 .filter(|(_, signatures)| signatures.iter().any(|s| s != &signatures[0]))
1013 .map(|(number, _)| number)
1014 .collect();
1015 divergent.sort_unstable();
1016 divergent
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021 use super::*;
1022 use git_workon_fixture::prelude::*;
1023
1024 #[test]
1025 fn reads_linear_stack_from_canonical() {
1026 let fixture = FixtureBuilder::new()
1027 .bare(true)
1028 .default_branch("main")
1029 .worktree("main")
1030 .gh_stack(None, 12, "main", &["feat-a", "feat-b"])
1031 .build()
1032 .unwrap();
1033 let repo = fixture.repo().unwrap();
1034
1035 let meta = read_metadata(repo).unwrap();
1036 assert_eq!(meta.trunks, vec!["main".to_string()]);
1037 assert_eq!(meta.parents["feat-a"].parent, "main");
1038 assert_eq!(meta.parents["feat-b"].parent, "feat-a");
1039 assert_eq!(meta.stack_numbers["feat-a"], 12);
1040 assert_eq!(meta.stack_numbers["feat-b"], 12);
1041
1042 let stacks = enumerate_stacks(repo).unwrap();
1043 assert_eq!(stacks.len(), 1);
1044 assert_eq!(stacks[0].number, Some(12));
1045 assert_eq!(stacks[0].diffs, vec!["feat-a", "feat-b"]);
1046 }
1047
1048 #[test]
1049 fn ghost_retained_by_current_stack_and_pruned_by_enumerate() {
1050 let fixture = FixtureBuilder::new()
1051 .bare(true)
1052 .default_branch("main")
1053 .worktree("main")
1054 .gh_stack(None, 5, "main", &["feat-a"])
1055 .gh_stack_ghost_branch(None, 5, "feat-b")
1056 .build()
1057 .unwrap();
1058 let repo = fixture.repo().unwrap();
1059
1060 let current = current_stack(repo, "feat-a").unwrap().expect("tracked");
1064 assert!(current.diffs.contains(&"feat-a".to_string()));
1065
1066 let enumerated = enumerate_stacks(repo).unwrap();
1067 assert_eq!(enumerated.len(), 1);
1068 assert!(!enumerated[0].diffs.contains(&"feat-b".to_string()));
1069 assert!(enumerated[0].diffs.contains(&"feat-a".to_string()));
1070 }
1071
1072 #[test]
1073 fn truncated_file_is_skipped() {
1074 let fixture = FixtureBuilder::new()
1075 .bare(true)
1076 .default_branch("main")
1077 .worktree("main")
1078 .raw_gh_stack(None, b"{\"schemaVersion\": 1, \"stacks\": [".to_vec())
1079 .build()
1080 .unwrap();
1081 let repo = fixture.repo().unwrap();
1082
1083 let meta = read_metadata(repo).unwrap();
1084 assert!(meta.trunks.is_empty());
1085 assert!(meta.parents.is_empty());
1086 }
1087
1088 #[test]
1089 fn schema_version_2_is_a_hard_error() {
1090 let fixture = FixtureBuilder::new()
1091 .bare(true)
1092 .default_branch("main")
1093 .worktree("main")
1094 .raw_gh_stack(None, br#"{"schemaVersion": 2, "stacks": []}"#.to_vec())
1095 .build()
1096 .unwrap();
1097 let repo = fixture.repo().unwrap();
1098
1099 match read_metadata(repo) {
1100 Err(StackError::GhStackSchemaUnsupported { version: 2, .. }) => {}
1101 Err(e) => panic!("expected GhStackSchemaUnsupported{{version: 2}}, got {e:?}"),
1102 Ok(_) => panic!("expected GhStackSchemaUnsupported{{version: 2}}, got Ok"),
1103 }
1104 }
1105
1106 #[test]
1107 fn missing_schema_version_defaults_to_1() {
1108 let fixture = FixtureBuilder::new()
1111 .bare(true)
1112 .default_branch("main")
1113 .worktree("main")
1114 .branch("feat-a")
1115 .raw_gh_stack(
1116 None,
1117 br#"{"stacks": [{"number": 1, "trunk": {"branch": "main", "head": "", "base": ""}, "branches": [{"branch": "feat-a", "head": "", "base": ""}]}]}"#.to_vec(),
1118 )
1119 .build()
1120 .unwrap();
1121 let repo = fixture.repo().unwrap();
1122
1123 let meta = read_metadata(repo).unwrap();
1124 assert_eq!(meta.parents["feat-a"].parent, "main");
1125 assert_eq!(meta.stack_numbers["feat-a"], 1);
1126 }
1127
1128 #[test]
1129 fn schema_version_0_defaults_to_1() {
1130 let fixture = FixtureBuilder::new()
1134 .bare(true)
1135 .default_branch("main")
1136 .worktree("main")
1137 .branch("feat-a")
1138 .raw_gh_stack(
1139 None,
1140 br#"{"schemaVersion": 0, "stacks": [{"number": 1, "trunk": {"branch": "main", "head": "", "base": ""}, "branches": [{"branch": "feat-a", "head": "", "base": ""}]}]}"#.to_vec(),
1141 )
1142 .build()
1143 .unwrap();
1144 let repo = fixture.repo().unwrap();
1145
1146 let meta = read_metadata(repo).unwrap();
1147 assert_eq!(meta.parents["feat-a"].parent, "main");
1148 assert_eq!(meta.stack_numbers["feat-a"], 1);
1149 }
1150
1151 #[test]
1152 fn needs_restack_true_when_base_differs_from_parent_live_tip() {
1153 let fixture = FixtureBuilder::new()
1154 .bare(true)
1155 .default_branch("main")
1156 .worktree("main")
1157 .gh_stack_at(
1158 None,
1159 1,
1160 "main",
1161 &[("feat-a", "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")],
1162 )
1163 .build()
1164 .unwrap();
1165 let repo = fixture.repo().unwrap();
1166
1167 let meta = read_metadata(repo).unwrap();
1168 let entry = meta.parents.get("feat-a").unwrap();
1169 assert_eq!(
1170 entry.parent_revision.as_deref(),
1171 Some("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
1172 );
1173 let main_tip = repo
1174 .find_branch("main", git2::BranchType::Local)
1175 .unwrap()
1176 .get()
1177 .target()
1178 .unwrap();
1179 assert_ne!(
1180 entry.parent_revision.as_deref(),
1181 Some(main_tip.to_string().as_str())
1182 );
1183 }
1184
1185 #[test]
1186 fn degraded_union_pulls_in_unlinked_worktree_file() {
1187 let fixture = FixtureBuilder::new()
1188 .bare(true)
1189 .default_branch("main")
1190 .worktree("main")
1191 .worktree("feat-a")
1192 .gh_stack(Some("feat-a"), 9, "main", &["feat-a"])
1193 .build()
1194 .unwrap();
1195 let repo = fixture.repo().unwrap();
1196
1197 let meta = read_metadata(repo).unwrap();
1198 assert_eq!(meta.parents["feat-a"].parent, "main");
1199 assert_eq!(meta.stack_numbers["feat-a"], 9);
1200 }
1201
1202 #[test]
1203 fn degraded_union_first_wins_on_disagreeing_unlinked_files() {
1204 let fixture = FixtureBuilder::new()
1208 .bare(true)
1209 .default_branch("main")
1210 .worktree("main")
1211 .worktree("feat-a")
1212 .worktree("feat-b")
1213 .gh_stack(Some("feat-a"), 1, "main", &["feat-a"])
1214 .gh_stack(Some("feat-b"), 1, "main", &["feat-b"])
1215 .build()
1216 .unwrap();
1217 let repo = fixture.repo().unwrap();
1218
1219 let meta = read_metadata(repo).unwrap();
1220 assert!(meta.parents.contains_key("feat-a"));
1221 assert!(!meta.parents.contains_key("feat-b"));
1222 }
1223
1224 #[test]
1227 fn two_numbered_stacks_in_one_file_are_not_divergent() {
1228 let fixture = FixtureBuilder::new()
1233 .bare(true)
1234 .default_branch("main")
1235 .branch("other-trunk")
1236 .worktree("main")
1237 .gh_stack(None, 1, "main", &["feat-a"])
1238 .gh_stack(None, 1, "other-trunk", &["feat-b"])
1239 .build()
1240 .unwrap();
1241 let repo = fixture.repo().unwrap();
1242
1243 assert!(divergent_stack_numbers(repo).is_empty());
1244 }
1245
1246 #[test]
1247 fn identical_copy_across_canonical_and_unlinked_is_not_divergent() {
1248 let fixture = FixtureBuilder::new()
1252 .bare(true)
1253 .default_branch("main")
1254 .worktree("main")
1255 .worktree("feat-a")
1256 .gh_stack(None, 4, "main", &["feat-a"])
1257 .gh_stack(Some("feat-a"), 4, "main", &["feat-a"])
1258 .build()
1259 .unwrap();
1260 let repo = fixture.repo().unwrap();
1261
1262 assert!(divergent_stack_numbers(repo).is_empty());
1263 }
1264
1265 #[test]
1266 fn genuinely_differing_copy_across_sources_is_divergent() {
1267 let fixture = FixtureBuilder::new()
1268 .bare(true)
1269 .default_branch("main")
1270 .worktree("main")
1271 .worktree("feat-a")
1272 .branch("feat-b")
1273 .gh_stack(None, 4, "main", &["feat-a"])
1274 .gh_stack(Some("feat-a"), 4, "main", &["feat-b"])
1275 .build()
1276 .unwrap();
1277 let repo = fixture.repo().unwrap();
1278
1279 assert_eq!(divergent_stack_numbers(repo), vec![4]);
1280 }
1281
1282 #[test]
1283 fn branch_spanning_two_stacks_keeps_the_first_stacks_parent_and_number() {
1284 let fixture = FixtureBuilder::new()
1291 .bare(true)
1292 .default_branch("main")
1293 .branch("other-trunk")
1294 .worktree("main")
1295 .gh_stack(None, 1, "main", &["shared"])
1296 .gh_stack(None, 2, "other-trunk", &["shared"])
1297 .build()
1298 .unwrap();
1299 let repo = fixture.repo().unwrap();
1300
1301 let meta = read_metadata(repo).unwrap();
1302 assert_eq!(meta.parents["shared"].parent, "main");
1303 assert_eq!(meta.stack_numbers["shared"], 1);
1304 }
1305
1306 #[test]
1309 fn link_worktree_plants_relative_symlinks() {
1310 let fixture = FixtureBuilder::new()
1311 .bare(true)
1312 .default_branch("main")
1313 .worktree("main")
1314 .worktree("feat-a")
1315 .build()
1316 .unwrap();
1317 let repo = fixture.repo().unwrap();
1318
1319 link_worktree(repo, "feat-a").unwrap();
1320
1321 repo.assert(predicate::repo::gh_stack_is_linked("feat-a"));
1322 let lock_target = std::fs::read_link(
1323 repo.commondir()
1324 .join("worktrees")
1325 .join("feat-a")
1326 .join("gh-stack.lock"),
1327 )
1328 .unwrap();
1329 assert_eq!(lock_target, Path::new("../../gh-stack.lock"));
1330 }
1331
1332 #[test]
1333 fn link_worktree_is_idempotent() {
1334 let fixture = FixtureBuilder::new()
1335 .bare(true)
1336 .default_branch("main")
1337 .worktree("main")
1338 .worktree("feat-a")
1339 .build()
1340 .unwrap();
1341 let repo = fixture.repo().unwrap();
1342
1343 link_worktree(repo, "feat-a").unwrap();
1344 link_worktree(repo, "feat-a").unwrap();
1345
1346 repo.assert(predicate::repo::gh_stack_is_linked("feat-a"));
1347 }
1348
1349 #[test]
1350 fn link_worktree_replaces_a_symlink_pointing_somewhere_wrong() {
1351 let fixture = FixtureBuilder::new()
1355 .bare(true)
1356 .default_branch("main")
1357 .worktree("main")
1358 .worktree("feat-a")
1359 .build()
1360 .unwrap();
1361 let repo = fixture.repo().unwrap();
1362 let admin_dir = repo.commondir().join("worktrees").join("feat-a");
1363
1364 create_symlink(Path::new("../../nonsense"), &admin_dir.join("gh-stack")).unwrap();
1365
1366 link_worktree(repo, "feat-a").unwrap();
1367
1368 repo.assert(predicate::repo::gh_stack_is_linked("feat-a"));
1369 }
1370
1371 #[test]
1372 fn link_worktree_never_replaces_a_real_file() {
1373 let fixture = FixtureBuilder::new()
1374 .bare(true)
1375 .default_branch("main")
1376 .worktree("main")
1377 .worktree("feat-a")
1378 .gh_stack(Some("feat-a"), 3, "main", &["feat-a"])
1379 .gh_stack_unlinked("feat-a")
1380 .build()
1381 .unwrap();
1382 let repo = fixture.repo().unwrap();
1383
1384 link_worktree(repo, "feat-a").unwrap();
1385
1386 repo.assert(predicate::repo::gh_stack_contains_branch(
1388 Some("feat-a"),
1389 "feat-a",
1390 0,
1391 ));
1392 let meta =
1393 std::fs::symlink_metadata(repo.commondir().join("worktrees/feat-a/gh-stack")).unwrap();
1394 assert!(!meta.file_type().is_symlink());
1395 }
1396
1397 #[test]
1398 fn migrate_worktree_merges_into_canonical_and_leaves_backup() {
1399 let fixture = FixtureBuilder::new()
1400 .bare(true)
1401 .default_branch("main")
1402 .worktree("main")
1403 .worktree("feat-a")
1404 .gh_stack(Some("feat-a"), 7, "main", &["feat-a"])
1405 .gh_stack_unlinked("feat-a")
1406 .build()
1407 .unwrap();
1408 let repo = fixture.repo().unwrap();
1409
1410 migrate_worktree(repo, "feat-a").unwrap();
1411
1412 repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-a", 0));
1414 repo.assert(predicate::repo::gh_stack_is_linked("feat-a"));
1416 assert!(repo
1418 .commondir()
1419 .join("worktrees/feat-a/gh-stack.bak")
1420 .exists());
1421
1422 let meta = read_metadata(repo).unwrap();
1423 assert_eq!(meta.stack_numbers["feat-a"], 7);
1424 }
1425
1426 #[test]
1427 fn migrate_worktree_preserves_top_level_fields_when_canonical_is_absent() {
1428 let fixture = FixtureBuilder::new()
1432 .bare(true)
1433 .default_branch("main")
1434 .worktree("main")
1435 .worktree("feat-a")
1436 .gh_stack(Some("feat-a"), 7, "main", &["feat-a"])
1437 .build()
1438 .unwrap();
1439 let repo = fixture.repo().unwrap();
1440
1441 migrate_worktree(repo, "feat-a").unwrap();
1442
1443 repo.assert(predicate::repo::gh_stack_preserves(
1444 None,
1445 "/repository",
1446 "git-workon-fixture/gh-stack",
1447 ));
1448 repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-a", 0));
1449 }
1450
1451 #[test]
1452 fn migrate_worktree_falls_back_to_link_when_nothing_to_merge() {
1453 let fixture = FixtureBuilder::new()
1454 .bare(true)
1455 .default_branch("main")
1456 .worktree("main")
1457 .worktree("feat-a")
1458 .build()
1459 .unwrap();
1460 let repo = fixture.repo().unwrap();
1461
1462 migrate_worktree(repo, "feat-a").unwrap();
1463
1464 repo.assert(predicate::repo::gh_stack_is_linked("feat-a"));
1465 assert!(!repo
1466 .commondir()
1467 .join("worktrees/feat-a/gh-stack.bak")
1468 .exists());
1469 }
1470
1471 #[test]
1472 fn migrate_worktree_never_clobbers_an_existing_backup() {
1473 let fixture = FixtureBuilder::new()
1474 .bare(true)
1475 .default_branch("main")
1476 .worktree("main")
1477 .worktree("feat-a")
1478 .gh_stack(Some("feat-a"), 7, "main", &["feat-a"])
1479 .gh_stack_unlinked("feat-a")
1480 .build()
1481 .unwrap();
1482 let repo = fixture.repo().unwrap();
1483
1484 migrate_worktree(repo, "feat-a").unwrap();
1485
1486 let admin_dir = repo.commondir().join("worktrees/feat-a");
1487 let first_backup = admin_dir.join("gh-stack.bak");
1488 assert!(first_backup.exists());
1489 let first_backup_contents = std::fs::read(&first_backup).unwrap();
1490
1491 std::fs::remove_file(admin_dir.join("gh-stack")).unwrap();
1494 std::fs::write(
1495 admin_dir.join("gh-stack"),
1496 br#"{"schemaVersion":1,"stacks":[]}"#,
1497 )
1498 .unwrap();
1499
1500 migrate_worktree(repo, "feat-a").unwrap();
1501
1502 assert_eq!(std::fs::read(&first_backup).unwrap(), first_backup_contents);
1504 assert!(admin_dir.join("gh-stack.bak.1").exists());
1506 repo.assert(predicate::repo::gh_stack_is_linked("feat-a"));
1507 }
1508
1509 #[test]
1510 fn migrate_worktree_also_migrates_a_stale_lock_file() {
1511 let fixture = FixtureBuilder::new()
1516 .bare(true)
1517 .default_branch("main")
1518 .worktree("main")
1519 .worktree("feat-a")
1520 .gh_stack(Some("feat-a"), 7, "main", &["feat-a"])
1521 .gh_stack_unlinked("feat-a")
1522 .gh_stack_lock_unlinked("feat-a")
1523 .build()
1524 .unwrap();
1525 let repo = fixture.repo().unwrap();
1526
1527 let admin_dir = repo.commondir().join("worktrees/feat-a");
1528 let lock_meta_before = std::fs::symlink_metadata(admin_dir.join("gh-stack.lock")).unwrap();
1529 assert!(!lock_meta_before.file_type().is_symlink());
1530
1531 migrate_worktree(repo, "feat-a").unwrap();
1532
1533 repo.assert(predicate::repo::gh_stack_is_linked("feat-a"));
1534 let lock_meta_after = std::fs::symlink_metadata(admin_dir.join("gh-stack.lock")).unwrap();
1535 assert!(
1536 lock_meta_after.file_type().is_symlink(),
1537 "gh-stack.lock must be a symlink after migration"
1538 );
1539 let lock_target = std::fs::read_link(admin_dir.join("gh-stack.lock")).unwrap();
1540 assert_eq!(lock_target, Path::new("../../gh-stack.lock"));
1541 }
1542
1543 #[test]
1544 fn worktree_link_status_reports_linked_for_a_fully_linked_worktree() {
1545 let fixture = FixtureBuilder::new()
1551 .bare(true)
1552 .default_branch("main")
1553 .worktree("main")
1554 .worktree("feat-a")
1555 .gh_stack(None, 1, "main", &["feat-a"])
1556 .gh_stack_linked("feat-a")
1557 .build()
1558 .unwrap();
1559 let repo = fixture.repo().unwrap();
1560
1561 assert_eq!(worktree_link_status(repo, "feat-a"), LinkStatus::Linked);
1562
1563 assert!(unlinked_files(repo).is_empty());
1567 assert!(divergent_stack_numbers(repo).is_empty());
1568 }
1569
1570 #[test]
1571 fn worktree_link_status_reports_not_linked_when_lock_is_a_regular_file() {
1572 let fixture = FixtureBuilder::new()
1577 .bare(true)
1578 .default_branch("main")
1579 .worktree("main")
1580 .worktree("feat-a")
1581 .gh_stack_linked("feat-a")
1582 .gh_stack_lock_unlinked("feat-a")
1583 .build()
1584 .unwrap();
1585 let repo = fixture.repo().unwrap();
1586
1587 match worktree_link_status(repo, "feat-a") {
1588 LinkStatus::NotLinked { holds_file } => {
1589 assert!(!holds_file);
1592 }
1593 LinkStatus::Linked => panic!("expected NotLinked, got Linked"),
1594 }
1595 }
1596
1597 #[test]
1600 fn register_branch_appends_onto_a_trunk_with_no_branches_yet() {
1601 let fixture = FixtureBuilder::new()
1602 .bare(true)
1603 .default_branch("main")
1604 .worktree("main")
1605 .branch("feat-a")
1606 .gh_stack(None, 1, "main", &[])
1607 .build()
1608 .unwrap();
1609 let repo = fixture.repo().unwrap();
1610
1611 register_branch(repo, "feat-a", "main").unwrap();
1612
1613 repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-a", 0));
1614 let head_oid = repo
1615 .find_branch("feat-a", git2::BranchType::Local)
1616 .unwrap()
1617 .get()
1618 .target()
1619 .unwrap();
1620 repo.assert(predicate::repo::gh_stack_branch_base(
1621 None,
1622 "feat-a",
1623 head_oid.to_string(),
1624 ));
1625 }
1626
1627 #[test]
1628 fn register_branch_appends_onto_the_top_of_an_existing_stack() {
1629 let fixture = FixtureBuilder::new()
1630 .bare(true)
1631 .default_branch("main")
1632 .worktree("main")
1633 .gh_stack(None, 1, "main", &["feat-a"])
1634 .branch("feat-b")
1635 .build()
1636 .unwrap();
1637 let repo = fixture.repo().unwrap();
1638
1639 register_branch(repo, "feat-b", "feat-a").unwrap();
1640
1641 repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-a", 0));
1642 repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-b", 1));
1643 }
1644
1645 #[test]
1646 fn register_branch_preserves_id_and_pull_request_on_untouched_entries() {
1647 let fixture = FixtureBuilder::new()
1651 .bare(true)
1652 .default_branch("main")
1653 .worktree("main")
1654 .branch("feat-a")
1655 .branch("feat-b")
1656 .raw_gh_stack(
1657 None,
1658 br#"{
1659 "schemaVersion": 1,
1660 "stacks": [{
1661 "id": "stack-abc",
1662 "number": 3,
1663 "trunk": { "branch": "main", "head": "", "base": "" },
1664 "branches": [{
1665 "branch": "feat-a",
1666 "head": "0000000000000000000000000000000000000a",
1667 "base": "0000000000000000000000000000000000000b",
1668 "pullRequest": { "number": 42, "id": "PR_1", "merged": false }
1669 }]
1670 }]
1671 }"#
1672 .to_vec(),
1673 )
1674 .build()
1675 .unwrap();
1676 let repo = fixture.repo().unwrap();
1677
1678 register_branch(repo, "feat-b", "feat-a").unwrap();
1679
1680 repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-a", 0));
1681 repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-b", 1));
1682 repo.assert(predicate::repo::gh_stack_preserves(
1683 None,
1684 "/stacks/0/id",
1685 "stack-abc",
1686 ));
1687 repo.assert(predicate::repo::gh_stack_preserves(
1688 None,
1689 "/stacks/0/branches/0/pullRequest/number",
1690 "42",
1691 ));
1692 }
1693
1694 #[test]
1695 fn register_branch_surfaces_parse_failed_for_truncated_canonical() {
1696 let fixture = FixtureBuilder::new()
1701 .bare(true)
1702 .default_branch("main")
1703 .worktree("main")
1704 .branch("feat-a")
1705 .branch("feat-b")
1706 .gh_stack(None, 1, "main", &["feat-a"])
1707 .raw_gh_stack(None, b"{\"schemaVersion\": 1, \"stacks\": [".to_vec())
1708 .build()
1709 .unwrap();
1710 let repo = fixture.repo().unwrap();
1711
1712 match register_branch(repo, "feat-b", "feat-a") {
1713 Err(StackError::GhStackParseFailed { .. }) => {}
1714 other => panic!("expected GhStackParseFailed, got {other:?}"),
1715 }
1716 }
1717
1718 #[test]
1719 fn register_branch_errors_when_no_stack_ends_at_base() {
1720 let fixture = FixtureBuilder::new()
1724 .bare(true)
1725 .default_branch("main")
1726 .worktree("main")
1727 .branch("feat-a")
1728 .branch("feat-b")
1729 .gh_stack(None, 1, "main", &["feat-a"])
1730 .build()
1731 .unwrap();
1732 let repo = fixture.repo().unwrap();
1733
1734 match register_branch(repo, "feat-b", "main") {
1735 Err(StackError::GhStackNoStackForBase { base }) => {
1736 assert_eq!(base, "main");
1737 }
1738 other => panic!("expected GhStackNoStackForBase, got {other:?}"),
1739 }
1740 }
1741
1742 #[test]
1743 fn register_branch_points_at_doctor_fix_when_stack_is_unlinked_only() {
1744 let fixture = FixtureBuilder::new()
1751 .bare(true)
1752 .default_branch("main")
1753 .worktree("main")
1754 .worktree("feat-a")
1755 .branch("feat-b")
1756 .gh_stack(Some("feat-a"), 1, "main", &["feat-a"])
1757 .build()
1758 .unwrap();
1759 let repo = fixture.repo().unwrap();
1760
1761 let meta = read_metadata(repo).unwrap();
1764 assert_eq!(meta.parents["feat-a"].parent, "main");
1765
1766 match register_branch(repo, "feat-b", "feat-a") {
1767 Err(StackError::GhStackStackInUnlinkedWorktree { base }) => {
1768 assert_eq!(base, "feat-a");
1769 }
1770 other => panic!("expected GhStackStackInUnlinkedWorktree, got {other:?}"),
1771 }
1772 }
1773}