Skip to main content

workon/stack/
gh_stack.rs

1//! `gh stack` (`github/gh-stack` CLI extension) stack detection — read path.
2//!
3//! Stack metadata is read without invoking `gh`. Upstream writes one JSON file per git dir
4//! (`schemaVersion: 1`, `{ repository, stacks: [{ id, number, trunk: branchRef, branches:
5//! [branchRef] }] }`, `branchRef = { branch, head, base, pullRequest }`), which for a linked
6//! worktree is per-worktree, not shared. workon keeps one canonical copy at
7//! `<common-dir>/gh-stack` and symlinks each worktree's admin-dir path to it (see
8//! [`link_worktree`]) so it behaves like Graphite's shared store.
9//!
10//! **Never use `repo.path()` here — always `repo.commondir()`.** `get_repo` (`get_repo.rs`)
11//! follows `commondir` back and returns the bare repo, so `repo.path() == repo.commondir()`
12//! at every CLI call site. But `Fixture::repo()` in tests can be a *worktree* handle where
13//! they differ. A `path()`-based scan silently passes under test and fails for every real
14//! linked worktree.
15//!
16//! ## Read order and the degraded union fallback
17//!
18//! [`read_metadata`] reads the canonical file first, then unions in [`unlinked_files`] —
19//! worktree admin-dir files that are *not* symlinks resolving to canonical — in directory
20//! order. In a healthy (fully-linked) repo `unlinked_files` is empty and the union never
21//! runs. It exists because write-in-place (upstream truncates its target through the
22//! symlink) is an implementation detail, not a contract: if gh-stack ever switches to
23//! temp-and-rename, the rename replaces a worktree's canonical symlink with a real file, and
24//! that worktree's writes silently stop reaching canonical. The union read means nothing goes
25//! invisible in the meantime — `doctor` (added later) flags any unlinked file it finds.
26//!
27//! Dedupe when the union fires: identity is `number` when non-zero, else `id` when
28//! non-empty, else `(trunk, first branch)`; **first wins wholesale** — the entire stack
29//! object from the earliest source is kept, later ones with the same identity are discarded
30//! entirely, never merged field-by-field. Merging two disagreeing ordered `branches` arrays
31//! has no defined semantics (an insertion in one is indistinguishable from a deletion in the
32//! other), so a field-level merge could synthesize a stack that existed in neither worktree.
33//!
34//! ## Truncated reads are tolerated, not fatal
35//!
36//! A partial file is the *expected* steady state during a concurrent `gh stack` command —
37//! upstream's `os.WriteFile` truncates in place rather than writing to a temp file and
38//! renaming, so a reader can observe a half-written file. [`read_metadata`] retries a
39//! read-and-parse up to 3 times, 25ms apart, and skips the file with `log::warn!` if every
40//! attempt still fails to parse. This is the deliberate opposite of Graphite's rule
41//! (`graphite.rs`'s `read_branch_metadata`, where a present-but-unreadable database is a hard
42//! error): sqlite writes are atomic, so unreadable there means corrupt, not mid-write.
43//!
44//! `schemaVersion > 1` is not retried — retrying a version mismatch cannot fix it, and
45//! skipping it would silently render a confidently wrong (outdated) stack, so it is a hard
46//! error ([`StackError::GhStackSchemaUnsupported`]). Missing or `0` is treated as `1`,
47//! matching Go's zero-value behavior for an unset int field.
48
49use 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/// One `branchRef` entry (`{ branch, head, base, pullRequest }`), pulled from a raw
64/// `serde_json::Value` rather than a derived struct (this crate has no `serde` derive
65/// dependency, only `serde_json`; see `graphite.rs` for the same raw-`Value` convention).
66/// `head` and `pullRequest` are read from the file but not carried into [`StackMetadata`]:
67/// assembly uses the live tip for `head`, and `pullRequest` has no `StackMetadata` field.
68/// Both matter to the write path (added in a later changeset), which round-trips the raw
69/// `Value` to preserve them.
70#[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
122/// Parse `doc`'s `stacks` array. Entries missing a well-formed `trunk` are skipped (not fatal
123/// — one malformed entry in an otherwise-valid file shouldn't blind the whole read).
124fn 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
131/// Number of read-and-parse attempts before a persistently truncated/malformed file is
132/// skipped with a warning. See the module docs' "Truncated reads" section.
133const READ_ATTEMPTS: u32 = 3;
134const READ_RETRY_DELAY: Duration = Duration::from_millis(25);
135
136/// `<common-dir>/gh-stack` — the canonical store every worktree's admin-dir file is meant to
137/// symlink to.
138pub(crate) fn canonical_path(repo: &Repository) -> PathBuf {
139    repo.commondir().join("gh-stack")
140}
141
142/// Worktree admin-dir `gh-stack` files that are NOT symlinks resolving to [`canonical_path`],
143/// sorted by directory name. Empty in the healthy (fully-linked) case. Directory-name order
144/// is the dedupe tiebreak in [`read_metadata`].
145pub(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
176/// `true` if `path` is a symlink whose target, resolved lexically relative to `path`'s parent
177/// (no `fs::canonicalize` — a dangling symlink to a not-yet-created canonical file is a valid
178/// state; see the module docs), equals `canonical`.
179fn 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
209/// Returns `true` if this repository has a gh-stack file anywhere workon knows to look —
210/// canonical or an unlinked worktree file.
211pub(crate) fn is_gh_stack_repo(repo: &Repository) -> bool {
212    canonical_path(repo).exists() || !unlinked_files(repo).is_empty()
213}
214
215/// Read, parse, and schema-check the gh-stack file at `path`.
216///
217/// Returns `Ok(None)` if the file does not exist, or if every read-and-parse attempt fails
218/// (logged via `log::warn!`) — both are non-fatal per the module docs. Returns `Err` only for
219/// `schemaVersion > 1`, which is never retried.
220fn 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/// Identity used to dedupe [`GhStackEntry`] values across canonical + unlinked files. See the
259/// module docs' "Read order and the degraded union fallback" section.
260#[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
282/// Read gh-stack's stack metadata into provider-agnostic [`StackMetadata`].
283///
284/// Reads canonical first, then unions in [`unlinked_files`] (directory order), deduping by
285/// [`StackIdentity`] with first-seen-wins. See the module docs for why the union is a
286/// degraded fallback rather than the primary path, and why first-wins never merges.
287pub(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        // branches[i].base maps to parent_revision, empty string normalizing to None
315        // (matches graphite.rs's treatment of parentBranchRevision); branches[i].head is
316        // discarded, assembly uses the branch's live tip instead.
317        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            // First-wins wholesale, matching `trunks` above: if a branch appears in two
325            // stacks, the earliest source's parent and stack number stick and `doctor` flags
326            // the divergence, rather than the last-seen source silently overwriting them.
327            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
350/// Return all gh-stack stacks, one per connected component, ghost branches PRUNED.
351pub(crate) fn enumerate_stacks(repo: &Repository) -> Result<Vec<Stack>, StackError> {
352    Ok(metadata::enumerate(repo, &read_metadata(repo)?))
353}
354
355/// Get the gh-stack stack for the worktree whose HEAD is `head_branch`, ghost branches
356/// RETAINED (see [`metadata::current`]).
357pub(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// ── Linking worktrees to the canonical file ─────────────────────────────────────────────
365
366/// RAII guard holding `<common-dir>/gh-stack.lock`'s `flock`. Released on drop.
367#[cfg(unix)]
368struct LockGuard(std::fs::File);
369
370#[cfg(unix)]
371impl Drop for LockGuard {
372    fn drop(&mut self) {
373        // SAFETY: `self.0` is a valid, open file descriptor for the whole guard lifetime.
374        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/// Take `<common-dir>/gh-stack.lock` (`flock(LOCK_EX | LOCK_NB)`, retried every 100ms up to
387/// 5s), so a concurrent `gh stack` run in any worktree is genuinely excluded — every
388/// worktree's lock path symlinks to this same file (see [`link_worktree`]). A no-op guard on
389/// non-unix targets, mirroring `graphite.rs`'s `#[cfg(not(unix))]` fallback.
390#[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) // lock file's contents (if any) are irrelevant; never clobber them
397        .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
432/// Plant `admin_dir/<filename>` as a relative symlink (`../../<filename>`) to
433/// `<common-dir>/<filename>`. Idempotent — a no-op if the symlink already points there.
434/// Never replaces a regular file: that is [`migrate_worktree`]'s job alone.
435fn 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(()); // already correctly linked
443            }
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(()), // a real file is here — never replace it, see migrate_worktree
456        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
471/// Plant `gh-stack` and `gh-stack.lock` in `<common>/worktrees/<worktree_name>/` as relative
472/// symlinks (`../../gh-stack`) to the canonical store. Idempotent. Never replaces a regular
473/// file — that is [`migrate_worktree`]'s job.
474///
475/// Safe to call before any stack exists: `open()` with `O_CREAT` through a dangling symlink
476/// creates the target, so the first `gh stack init` in any linked worktree creates canonical.
477/// See the module docs.
478pub(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
485/// Identity computed straight from a raw `stacks[]` entry `Value`, mirroring [`identity`] but
486/// without parsing into [`GhStackEntry`] first — used by [`migrate_worktree`], which must
487/// preserve `id`/`pullRequest`/`head` verbatim rather than round-tripping through the
488/// read-path's lossy struct.
489fn 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
515/// Read `path` as a whole raw `Value` (no [`GhStackEntry`] parsing, so every top-level field —
516/// `repository`, `id`, `pullRequest`, anything a future gh-stack adds — survives), rejecting
517/// `schemaVersion > 1`. `Ok(None)` for a missing file. A single attempt, no retries: called
518/// only under [`lock_canonical`] during `doctor --fix` or [`register_branch`], not on the hot
519/// read path [`read_doc`] serves.
520fn 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
549/// Read `path`'s `stacks[]` array as raw `Value`s (no [`GhStackEntry`] parsing, so `id` and
550/// `pullRequest` survive). Missing file, or a file with no `stacks` array, is an empty vec.
551fn 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
557/// Merge a worktree's real `gh-stack` file into canonical, then replace it with a symlink.
558/// Writes `gh-stack.bak` alongside the original before removing it. Takes the canonical lock
559/// throughout.
560///
561/// This is the only place workon replaces a file another tool wrote, so it is reachable only
562/// from `doctor --fix` — never automatically, never from `workon new`. If `worktree_name`'s
563/// `gh-stack` path is missing or already a symlink, this degrades to [`link_worktree`]: there
564/// is no real file to migrate.
565///
566/// Merge order matches [`read_metadata`]'s dedupe rule: canonical entries are seeded first, so
567/// a colliding identity in the worktree file is dropped, never merged field-by-field.
568pub(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    // Base the merged document on canonical's whole `Value` when it exists, falling back to
587    // the worktree file's, so every top-level field outside `stacks` — `repository` above
588    // all — round-trips instead of being discarded. Mirrors `plan_registered_doc`, which
589    // round-trips the same way for the same reason.
590    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    // Verify the merged result parses before touching anything on disk or unlinking the
613    // worktree's original — never destroy the only copy of data that failed to round-trip.
614    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    // Re-read the bytes actually on disk (not just the in-memory copy) before unlinking the
634    // worktree's original file. `read_raw_stacks`, not `read_doc`: `read_doc` only ever
635    // returns `Err` for a schema mismatch that can't happen on a doc we just wrote with
636    // `schemaVersion: 1`, so it can never fail here and isn't a real guard. `read_raw_stacks`
637    // is single-attempt and genuinely errors on a parse failure.
638    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
650/// Remove `admin_dir/gh-stack.lock` if it is a regular file, so the following
651/// [`link_worktree`] call can plant a proper symlink — [`plant_link`] never replaces a regular
652/// file. Its contents are irrelevant (upstream never reads them, it is a pure `flock` target;
653/// see the module docs' shared-canonical-file section), so simply discarding it is safe.
654///
655/// A worktree that has already run `gh stack` almost always has a real `gh-stack.lock`
656/// alongside its real `gh-stack` file (upstream opens it `O_CREATE` on every lock attempt), so
657/// this runs on every path through [`migrate_worktree`], not only the merge path — leaving it
658/// behind would silently keep that worktree flocking a private inode instead of the shared
659/// canonical lock, defeating cross-worktree mutual exclusion without [`worktree_link_status`]
660/// (which now checks both paths) reporting it.
661fn 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
676/// The first of `gh-stack.bak`, `gh-stack.bak.1`, `gh-stack.bak.2`, ... that doesn't already
677/// exist in `admin_dir`. A worktree can legitimately acquire a real `gh-stack` file again
678/// after a prior migration — the write-in-place risk this crate's module docs describe (a
679/// gh-stack release switching to temp-and-rename would cause exactly this) — so re-migrating
680/// must never clobber the backup a previous migration left behind.
681fn 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
692// ── Registering new branches (write path) ───────────────────────────────────────────────
693
694/// Resolve `name`'s local branch tip. `workon new` has already created both `branch` and
695/// (normally) `base_branch` by the time [`register_branch`] runs, so failure here means
696/// something is badly wrong rather than an expected condition — reported as
697/// [`StackError::GhStackWriteFailed`] since there's no more specific variant for "the thing
698/// we were asked to register doesn't resolve to a commit".
699fn 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
715/// Find the index in `stacks` (a `stacks[]` array of raw `Value`s) whose stack currently ends
716/// at `base_branch`: either its last `branches` element is `base_branch`, or it has no
717/// `branches` yet and its `trunk.branch` is `base_branch`. First match wins when more than
718/// one qualifies — `doctor`'s `GhStackDivergentStacks` check is what flags that situation,
719/// not this function.
720fn 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
749/// Build the full replacement document (as pretty-printed bytes) for `register_branch`,
750/// given the raw bytes currently on disk (`existing`, possibly empty for "file doesn't exist
751/// yet"). Round-trips through `serde_json::Value` rather than a typed struct so `id`,
752/// `pullRequest`, and any other field on untouched `stacks[]` entries survive unchanged —
753/// only the target stack's `branches` array gains one new, minimal entry.
754fn 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    // `pullRequest` is deliberately omitted, matching upstream's `omitempty` on a fresh entry.
797    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
812/// Write `bytes` to `canonical` via `<common-dir>/gh-stack.tmp` then `fs::rename`, mode 0644.
813/// Atomic, unlike upstream's `os.WriteFile` — always call this with `canonical` itself
814/// ([`canonical_path`]'s return value), never a worktree's symlinked admin-dir path: renaming
815/// onto a symlink replaces the link with a real file instead of updating what it points to,
816/// silently detaching that worktree from the shared store.
817fn 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
841/// Append `branch` to the canonical file's stack that currently ends at `base_branch`.
842/// Round-trips through `serde_json::Value` — a typed struct would silently drop `id`,
843/// `pullRequest`, and any field a future gh-stack adds.
844///
845/// `base` is `repo.merge_base(base_branch's tip, branch's tip)` — equal to `base_branch`'s
846/// tip in the normal case, but still correct if `base_branch` moved between worktree creation
847/// and this call. Guarded by [`lock_canonical`], so a concurrent `gh stack` run in any
848/// worktree (every worktree's lock symlinks to the same file) is genuinely excluded.
849///
850/// The file is read only after the lock is held. No lock-respecting writer (every `gh stack`
851/// invocation, and every other `git-workon` call into this module) can be mid-write once the
852/// lock is ours, so a read-under-lock always sees a complete file — there is nothing to
853/// compare-and-swap against. A pre-lock read would risk observing upstream's non-atomic
854/// `os.WriteFile` mid-truncation and handing a partial prefix to [`plan_registered_doc`],
855/// which is exactly the failure this ordering avoids.
856pub 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        // `read_metadata` (used by `list`/`find`) unions canonical with `unlinked_files`, but
878        // this function reads canonical alone — deliberately, since it must never write
879        // through a worktree symlink (see `write_canonical_atomic`'s docs). So the spec's
880        // accepted chicken-and-egg case (someone runs `gh stack init` inside a worktree before
881        // ever running `doctor --fix`) reads fine everywhere but fails registration here with
882        // a message that looks identical to "no such stack at all". Point at the fix instead.
883        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// ── `doctor` support ─────────────────────────────────────────────────────────────────────
894
895/// Per-worktree link status, for `doctor`'s `GhStackWorktreeNotLinked` check and its `--fix`.
896#[derive(Debug, Clone, Copy, PartialEq, Eq)]
897pub enum LinkStatus {
898    /// Correctly symlinked to canonical.
899    Linked,
900    /// Not linked. `holds_file` distinguishes the two `--fix` actions: `true` means a real
901    /// file is present and must be merged ([`migrate_worktree`]); `false` means the path is
902    /// simply missing (or a symlink pointing somewhere else) and can be planted directly
903    /// ([`link_worktree`]).
904    NotLinked { holds_file: bool },
905}
906
907/// `Linked`/`NotLinked { holds_file }` for a single admin-dir path against `expected_target`
908/// — the canonical file *that path's own filename* symlinks to (`<common-dir>/gh-stack` for a
909/// `gh-stack` path, `<common-dir>/gh-stack.lock` for a `gh-stack.lock` path). Factored out of
910/// [`worktree_link_status`] so it can be applied to both filenames — a stale `gh-stack.lock`
911/// regular file (upstream opens it `O_CREATE` on every lock attempt, so a worktree that has run
912/// `gh stack` almost always has one) is just as unlinked as a stale `gh-stack` file, and must be
913/// equally visible to `doctor`.
914fn 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
928/// Compute [`LinkStatus`] for `worktree_name`'s `gh-stack` and `gh-stack.lock` admin-dir
929/// paths, `NotLinked` if either is unlinked. `holds_file` reflects only the `gh-stack` path —
930/// whether there is a real stack file needing [`migrate_worktree`]'s merge — never the lock
931/// file, whose contents are irrelevant and whose own staleness is fully handled by
932/// [`migrate_worktree`] discarding it before relinking; a stale lock alone must route through
933/// [`link_worktree`], not `migrate_worktree`.
934///
935/// Each path is checked against its own target: `plant_link` symlinks `gh-stack` to
936/// `../../gh-stack` and `gh-stack.lock` to `../../gh-stack.lock`, so comparing both against
937/// `canonical_path` alone would mean the `gh-stack.lock` check can never resolve to it —
938/// `is_symlink_resolving_to` compares the *lock's* resolved target against the *stack file's*
939/// path, which are never equal.
940pub(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
956/// Files (canonical + [`unlinked_files`]) that exist but fail to parse, or whose
957/// `schemaVersion` is unsupported — for `doctor`'s `GhStackFileUnreadable` check.
958///
959/// Unlike [`read_doc`], this is a single-attempt read: `doctor` is a point-in-time health
960/// check, not the hot read path a concurrent `gh stack` write races against, so there is no
961/// truncated-read tolerance to preserve here — a transient mid-write read just gets reported
962/// and re-checked on the next `doctor` run.
963pub(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
977/// Stack numbers that appear, with genuinely different content, in more than one gh-stack
978/// source — only possible when the degraded union read (see the module docs) actually combines
979/// canonical with an unlinked worktree file. For `doctor`'s `GhStackDivergentStacks` check.
980///
981/// Each number is counted at most once *per source*, so two stacks numbered 1 inside a single
982/// file don't get flagged as spanning "more than one gh-stack source" — that phrase means
983/// files, not array entries. And a number is only reported when its sources disagree: an
984/// unlinked worktree file holding a byte-identical copy of a canonical stack (the common state
985/// right after someone copies a worktree) is compared by content — at minimum its branch list —
986/// so an identical copy is not reported.
987pub(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    // number -> one branch-list signature per source that contains it (deduped within that
992    // source, so a file with two same-numbered stacks contributes one signature, not two).
993    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        // current_stack retains the ghost when walking from a live descendant... but feat-b
1061        // has no ref, so retrieve current_stack from feat-a (the live branch) instead, which
1062        // must still see feat-b was never linked as a child in enumerate's pruned output.
1063        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        // The module doc claims a missing `schemaVersion` is treated as `1`, matching Go's
1109        // zero-value behavior for an unset int field. No prior test omitted the field.
1110        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        // Same claim, explicit `schemaVersion: 0` — Go's own zero value for the field, and
1131        // distinct from "the field is absent" (missing_schema_version_defaults_to_1 above),
1132        // since the two arrive through different branches of `.filter(|&v| v != 0)`.
1133        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        // Two worktrees each hold their own unlinked file, both claiming stack number 1 for
1205        // a different branch set. Canonical is empty, so both are unioned; directory-name
1206        // order ("feat-a" < "feat-b") makes feat-a's file win the number-1 identity.
1207        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    // ── divergent_stack_numbers ─────────────────────────────────────────────────
1225
1226    #[test]
1227    fn two_numbered_stacks_in_one_file_are_not_divergent() {
1228        // Regression test for finding H(a): divergent_stack_numbers used to increment a
1229        // global per-number counter across all sources, so two *different* stacks both
1230        // numbered 1 inside the SAME file tripped count > 1, contradicting the doc comment
1231        // "appear in more than one gh-stack source" (source means file, not array entry).
1232        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        // Regression test for finding H(b): an unlinked worktree file holding a byte-identical
1249        // copy of a canonical stack is the common state right after someone copies a
1250        // worktree, not a real divergence, so it must not be flagged.
1251        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        // Regression test for finding E: read_metadata's flattening loop deduped `trunks`
1285        // first-wins but wrote `parents`/`stack_numbers` last-wins, contradicting the module
1286        // doc's "first wins wholesale" and the spec's "first-seen wins, doctor flags it". Two
1287        // canonical stacks, both listing "shared" — stack 1 comes first in file order, so its
1288        // parent ("main") and number (1) must stick even though stack 2 ("other-trunk", 2) is
1289        // read afterward.
1290        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    // ── link_worktree / migrate_worktree ────────────────────────────────────────
1307
1308    #[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        // plant_link has three arms: already-correct (link_worktree_is_idempotent), missing
1352        // (link_worktree_plants_relative_symlinks), and remove-and-recreate for a symlink that
1353        // exists but resolves elsewhere. Only the first two had coverage.
1354        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        // Still a real file — link_worktree must never clobber it.
1387        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        // Merged into canonical...
1413        repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-a", 0));
1414        // ...and the worktree is now linked to it.
1415        repo.assert(predicate::repo::gh_stack_is_linked("feat-a"));
1416        // ...with a backup of the original left behind.
1417        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        // No `gh_stack`/`gh_stack_at` call targets `None` (canonical), so canonical doesn't
1429        // exist before migration and the merged document must be seeded from the worktree
1430        // file's whole `Value` — including `repository` — not synthesized from scratch.
1431        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        // Simulate a gh-stack release switching to temp-and-rename: the symlink this
1492        // worktree's `gh-stack` path was left as gets replaced with a real file again.
1493        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        // The first backup is untouched...
1503        assert_eq!(std::fs::read(&first_backup).unwrap(), first_backup_contents);
1504        // ...and the second migration's original landed in a numbered backup instead.
1505        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        // A worktree that has already run `gh stack` almost always has a real `gh-stack.lock`
1512        // alongside its real `gh-stack` file (upstream opens it O_CREATE on every lock
1513        // attempt). Both must end up symlinked, or this worktree's `gh stack` keeps flocking a
1514        // private inode while `register_branch` flocks the shared canonical lock.
1515        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        // Regression test: link_status_for_path used to compare BOTH the gh-stack and
1546        // gh-stack.lock paths against canonical_path (the gh-stack target), but plant_link
1547        // symlinks gh-stack.lock to `../../gh-stack.lock`, which can never resolve to
1548        // `../../gh-stack`. A correctly, fully linked worktree reported NotLinked forever, and
1549        // no test anywhere asserted LinkStatus::Linked, which is why this regression shipped.
1550        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        // Healthy-path invariants that also had no coverage: a fully linked worktree leaves
1564        // nothing for the degraded union fallback to pick up, and no stack number collides
1565        // with itself across sources.
1566        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        // gh-stack itself is correctly linked, but gh-stack.lock reverted to a real file (the
1573        // write-in-place risk this module's docs describe). worktree_link_status must catch
1574        // this from the gh-stack path alone being insufficient — doctor would otherwise report
1575        // `Linked` forever with no way to detect the lost cross-worktree exclusion.
1576        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                // holds_file describes the gh-stack path, not the lock, and the gh-stack path
1590                // here is correctly linked (not holding a real file).
1591                assert!(!holds_file);
1592            }
1593            LinkStatus::Linked => panic!("expected NotLinked, got Linked"),
1594        }
1595    }
1596
1597    // ── register_branch ─────────────────────────────────────────────────────────
1598
1599    #[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        // The existing entry's `id` and its branch's `pullRequest` are fields workon never
1648        // reads. A typed struct would silently drop them on write; the raw-Value round-trip
1649        // must not.
1650        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        // A truncated canonical file under an uncontended lock is genuine corruption, not a
1697        // mid-write race (a lock-respecting writer can't be mid-write once we hold the lock).
1698        // The read-under-lock ordering means this must deterministically surface
1699        // GhStackParseFailed rather than a stale pre-lock snapshot masking the truncation.
1700        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        // "main" is a real branch, but the only stack's last branch is "feat-a", not "main",
1721        // and its `branches` isn't empty, so "main" doesn't match either target-selection
1722        // rule.
1723        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        // Finding F: the chicken-and-egg case the spec explicitly accepts — `gh stack init`
1745        // run inside a worktree before `doctor --fix` ever migrates it. `read_metadata` unions
1746        // in unlinked_files, so `list`/`find` render the stack correctly, but `register_branch`
1747        // reads canonical alone (it must never write through a worktree symlink) and would
1748        // otherwise report the same generic GhStackNoStackForBase as "no such stack anywhere",
1749        // which is indistinguishable from user error. It must instead point at `doctor --fix`.
1750        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        // Sanity: read_metadata (the list/find path) sees the stack fine via the degraded
1762        // union, so the failure below is specific to register_branch's canonical-only read.
1763        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}