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`. gh-stack >=0.2 keeps one shared JSON catalog
4//! at `<common-dir>/gh-stack` (`schemaVersion: 1`, `{ repository, stacks: [{ id, number,
5//! trunk: branchRef, branches: [branchRef] }] }`, `branchRef = { branch, head, base,
6//! pullRequest }`), written with temp-and-rename, and migrates any per-worktree file it finds
7//! into it. Upstream owns that store: workon never creates, links, or migrates it. It reads
8//! the catalog, unions in older per-worktree files (see below), and appends new branches via
9//! [`register_branch`]. Earlier workon versions symlinked each worktree's admin-dir path to
10//! the catalog; gh-stack >=0.2 rejects a symlink there, so workon no longer plants any.
11//!
12//! The write path ([`register_branch`]) follows upstream's locking: flock
13//! `<common-dir>/gh-stack-operation.lock`, then `<common-dir>/gh-stack.lock`, then refuse if
14//! the `<common-dir>/gh-stack-migration` journal exists, and only then read, plan, and write.
15//!
16//! **Never use `repo.path()` here — always `repo.commondir()`.** `get_repo` (`get_repo.rs`)
17//! follows `commondir` back and returns the bare repo, so `repo.path() == repo.commondir()`
18//! at every CLI call site. But `Fixture::repo()` in tests can be a *worktree* handle where
19//! they differ. A `path()`-based scan silently passes under test and fails for every real
20//! linked worktree.
21//!
22//! ## Read order and the degraded union fallback
23//!
24//! [`read_metadata`] reads the canonical file first, then unions in [`unlinked_files`] —
25//! worktree admin-dir files that are *not* symlinks resolving to canonical — in directory
26//! order. With gh-stack >=0.2 the union is empty once upstream has migrated those files. It
27//! stays as the fallback for gh-stack <0.2, which still writes a real file per worktree, and
28//! for files upstream hasn't migrated yet. `doctor` flags any unlinked file it finds.
29//!
30//! Dedupe when the union fires: identity is `number` when non-zero, else `id` when
31//! non-empty, else `(trunk, first branch)`; **first wins wholesale** — the entire stack
32//! object from the earliest source is kept, later ones with the same identity are discarded
33//! entirely, never merged field-by-field. Merging two disagreeing ordered `branches` arrays
34//! has no defined semantics (an insertion in one is indistinguishable from a deletion in the
35//! other), so a field-level merge could synthesize a stack that existed in neither worktree.
36//!
37//! ## Truncated reads are tolerated, not fatal
38//!
39//! A partial file can be observed during a concurrent `gh stack` command that writes in
40//! place, which gh-stack <0.2 still does for its per-worktree files (>=0.2 writes the catalog
41//! with temp-and-rename). [`read_metadata`] retries a read-and-parse up to 3 times, 25ms apart, and skips the file with `log::warn!` if every
42//! attempt still fails to parse. This is the deliberate opposite of Graphite's rule
43//! (`graphite.rs`'s `read_branch_metadata`, where a present-but-unreadable database is a hard
44//! error): sqlite writes are atomic, so unreadable there means corrupt, not mid-write.
45//!
46//! `schemaVersion > 1` is not retried — retrying a version mismatch cannot fix it, and
47//! skipping it would silently render a confidently wrong (outdated) stack, so it is a hard
48//! error ([`StackError::GhStackSchemaUnsupported`]). Missing or `0` is treated as `1`,
49//! matching Go's zero-value behavior for an unset int field.
50
51use std::collections::{HashMap, HashSet};
52use std::path::{Path, PathBuf};
53use std::time::Duration;
54
55use git2::Repository;
56use serde_json::Value;
57
58#[cfg(unix)]
59use std::os::unix::io::AsRawFd;
60
61use super::metadata::{self, BranchMetadata, StackMetadata};
62use super::Stack;
63use crate::error::StackError;
64
65#[derive(Debug)]
66struct GhStackBranchRef {
67    branch: String,
68    base: String,
69    merged: bool,
70}
71
72impl GhStackBranchRef {
73    fn from_value(value: &Value) -> Option<Self> {
74        Some(Self {
75            branch: value.get("branch")?.as_str()?.to_string(),
76            base: value
77                .get("base")
78                .and_then(|v| v.as_str())
79                .unwrap_or_default()
80                .to_string(),
81            merged: value
82                .get("pullRequest")
83                .and_then(|pr| pr.get("merged"))
84                .and_then(|v| v.as_bool())
85                .unwrap_or(false),
86        })
87    }
88}
89
90#[derive(Debug)]
91struct GhStackEntry {
92    id: String,
93    number: u64,
94    trunk: GhStackBranchRef,
95    branches: Vec<GhStackBranchRef>,
96}
97
98impl GhStackEntry {
99    fn from_value(value: &Value) -> Option<Self> {
100        let trunk = GhStackBranchRef::from_value(value.get("trunk")?)?;
101        let branches = value
102            .get("branches")
103            .and_then(|v| v.as_array())
104            .map(|arr| {
105                arr.iter()
106                    .filter_map(GhStackBranchRef::from_value)
107                    .collect()
108            })
109            .unwrap_or_default();
110        Some(Self {
111            id: value
112                .get("id")
113                .and_then(|v| v.as_str())
114                .unwrap_or_default()
115                .to_string(),
116            number: value.get("number").and_then(|v| v.as_u64()).unwrap_or(0),
117            trunk,
118            branches,
119        })
120    }
121}
122
123/// Parse `doc`'s `stacks` array. Entries missing a well-formed `trunk` are skipped (not fatal
124/// — one malformed entry in an otherwise-valid file shouldn't blind the whole read).
125fn parse_stacks(doc: &Value) -> Vec<GhStackEntry> {
126    doc.get("stacks")
127        .and_then(|v| v.as_array())
128        .map(|arr| arr.iter().filter_map(GhStackEntry::from_value).collect())
129        .unwrap_or_default()
130}
131
132/// Number of read-and-parse attempts before a persistently truncated/malformed file is
133/// skipped with a warning. See the module docs' "Truncated reads" section.
134const READ_ATTEMPTS: u32 = 3;
135const READ_RETRY_DELAY: Duration = Duration::from_millis(25);
136
137/// `<common-dir>/gh-stack` — the canonical store every worktree's admin-dir file is meant to
138/// symlink to.
139pub(crate) fn canonical_path(repo: &Repository) -> PathBuf {
140    repo.commondir().join("gh-stack")
141}
142
143/// Worktree admin-dir `gh-stack` files that are NOT symlinks resolving to [`canonical_path`],
144/// sorted by directory name. Empty in the healthy (fully-linked) case. Directory-name order
145/// is the dedupe tiebreak in [`read_metadata`].
146pub(crate) fn unlinked_files(repo: &Repository) -> Vec<PathBuf> {
147    let canonical = canonical_path(repo);
148    let worktrees_dir = repo.commondir().join("worktrees");
149    let Ok(entries) = std::fs::read_dir(&worktrees_dir) else {
150        return vec![];
151    };
152
153    let mut names: Vec<String> = entries
154        .filter_map(|e| e.ok())
155        .filter(|e| e.path().is_dir())
156        .filter_map(|e| e.file_name().into_string().ok())
157        .collect();
158    names.sort();
159
160    names
161        .into_iter()
162        .filter_map(|name| {
163            let path = worktrees_dir.join(&name).join("gh-stack");
164            if !path_exists_at_all(&path) || is_symlink_resolving_to(&path, &canonical) {
165                None
166            } else {
167                Some(path)
168            }
169        })
170        .collect()
171}
172
173fn path_exists_at_all(path: &Path) -> bool {
174    std::fs::symlink_metadata(path).is_ok()
175}
176
177/// `true` if `path` is a symlink whose target, resolved lexically relative to `path`'s parent
178/// (no `fs::canonicalize` — a dangling symlink to a not-yet-created canonical file is a valid
179/// state; see the module docs), equals `canonical`.
180fn is_symlink_resolving_to(path: &Path, canonical: &Path) -> bool {
181    let Ok(meta) = std::fs::symlink_metadata(path) else {
182        return false;
183    };
184    if !meta.file_type().is_symlink() {
185        return false;
186    }
187    let Ok(target) = std::fs::read_link(path) else {
188        return false;
189    };
190    let Some(parent) = path.parent() else {
191        return false;
192    };
193    normalize_lexically(&parent.join(target)) == normalize_lexically(canonical)
194}
195
196fn normalize_lexically(path: &Path) -> PathBuf {
197    let mut out = PathBuf::new();
198    for component in path.components() {
199        match component {
200            std::path::Component::ParentDir => {
201                out.pop();
202            }
203            std::path::Component::CurDir => {}
204            other => out.push(other.as_os_str()),
205        }
206    }
207    out
208}
209
210/// Returns `true` if this repository has a gh-stack file anywhere workon knows to look —
211/// canonical or an unlinked worktree file.
212pub(crate) fn is_gh_stack_repo(repo: &Repository) -> bool {
213    canonical_path(repo).exists() || !unlinked_files(repo).is_empty()
214}
215
216/// Read, parse, and schema-check the gh-stack file at `path`.
217///
218/// Returns `Ok(None)` if the file does not exist, or if every read-and-parse attempt fails
219/// (logged via `log::warn!`) — both are non-fatal per the module docs. Returns `Err` only for
220/// `schemaVersion > 1`, which is never retried.
221fn read_doc(path: &Path) -> Result<Option<Vec<GhStackEntry>>, StackError> {
222    let mut last_error: Option<String> = None;
223
224    for attempt in 0..READ_ATTEMPTS {
225        match std::fs::read(path) {
226            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
227            Err(e) => last_error = Some(e.to_string()),
228            Ok(bytes) => match serde_json::from_slice::<Value>(&bytes) {
229                Err(e) => last_error = Some(e.to_string()),
230                Ok(value) => {
231                    let version = value
232                        .get("schemaVersion")
233                        .and_then(|v| v.as_u64())
234                        .filter(|&v| v != 0)
235                        .unwrap_or(1);
236                    if version > 1 {
237                        return Err(StackError::GhStackSchemaUnsupported {
238                            path: path.to_path_buf(),
239                            version,
240                        });
241                    }
242                    return Ok(Some(parse_stacks(&value)));
243                }
244            },
245        }
246        if attempt + 1 < READ_ATTEMPTS {
247            std::thread::sleep(READ_RETRY_DELAY);
248        }
249    }
250
251    log::warn!(
252        "gh-stack: skipping unreadable file {}: {}",
253        path.display(),
254        last_error.unwrap_or_default()
255    );
256    Ok(None)
257}
258
259/// Identity used to dedupe [`GhStackEntry`] values across canonical + unlinked files. See the
260/// module docs' "Read order and the degraded union fallback" section.
261#[derive(Debug, PartialEq, Eq, Hash)]
262enum StackIdentity {
263    Number(u64),
264    Id(String),
265    TrunkAndFirstBranch(String, String),
266}
267
268fn identity(entry: &GhStackEntry) -> StackIdentity {
269    if entry.number != 0 {
270        StackIdentity::Number(entry.number)
271    } else if !entry.id.is_empty() {
272        StackIdentity::Id(entry.id.clone())
273    } else {
274        let first_branch = entry
275            .branches
276            .first()
277            .map(|b| b.branch.clone())
278            .unwrap_or_default();
279        StackIdentity::TrunkAndFirstBranch(entry.trunk.branch.clone(), first_branch)
280    }
281}
282
283/// Read gh-stack's stack metadata into provider-agnostic [`StackMetadata`].
284///
285/// Reads canonical first, then unions in [`unlinked_files`] (directory order), deduping by
286/// [`StackIdentity`] with first-seen-wins. See the module docs for why the union is a
287/// degraded fallback rather than the primary path, and why first-wins never merges.
288pub(crate) fn read_metadata(repo: &Repository) -> Result<StackMetadata, StackError> {
289    let mut seen: HashSet<StackIdentity> = HashSet::new();
290    let mut kept: Vec<GhStackEntry> = Vec::new();
291
292    let mut sources = vec![canonical_path(repo)];
293    sources.extend(unlinked_files(repo));
294
295    for path in sources {
296        let Some(entries) = read_doc(&path)? else {
297            continue;
298        };
299        for entry in entries {
300            if seen.insert(identity(&entry)) {
301                kept.push(entry);
302            }
303        }
304    }
305
306    let mut trunks: Vec<String> = Vec::new();
307    let mut parents: HashMap<String, BranchMetadata> = HashMap::new();
308    let mut stack_numbers: HashMap<String, u64> = HashMap::new();
309
310    for entry in &kept {
311        if !trunks.contains(&entry.trunk.branch) {
312            trunks.push(entry.trunk.branch.clone());
313        }
314
315        // branches[i].base maps to parent_revision, empty string normalizing to None
316        // (matches graphite.rs's treatment of parentBranchRevision); branches[i].head is
317        // discarded, assembly uses the branch's live tip instead.
318        let mut parent = entry.trunk.branch.clone();
319        for branch_ref in &entry.branches {
320            let parent_revision = if branch_ref.base.is_empty() {
321                None
322            } else {
323                Some(branch_ref.base.clone())
324            };
325            // First-wins wholesale, matching `trunks` above: if a branch appears in two
326            // stacks, the earliest source's parent and stack number stick and `doctor` flags
327            // the divergence, rather than the last-seen source silently overwriting them.
328            parents
329                .entry(branch_ref.branch.clone())
330                .or_insert(BranchMetadata {
331                    parent: parent.clone(),
332                    parent_revision,
333                    merged: branch_ref.merged,
334                });
335            if entry.number != 0 {
336                stack_numbers
337                    .entry(branch_ref.branch.clone())
338                    .or_insert(entry.number);
339            }
340            parent = branch_ref.branch.clone();
341        }
342    }
343
344    Ok(StackMetadata {
345        trunks,
346        parents,
347        pr_titles: HashMap::new(),
348        stack_numbers,
349    })
350}
351
352/// `true` if gh-stack metadata has at least one unmerged non-trunk branch whose ref still
353/// resolves.
354///
355/// Same contract as [`graphite::has_live_branches`](super::graphite::has_live_branches) — see
356/// its docs for why this checks `parents`' keys (never `trunks`), why an unreadable store
357/// yields `false` rather than erroring, and why this must never probe an external binary.
358/// Merged rows don't count: `gh stack sync` keeps them, and `--prune` can't delete a branch a
359/// worktree has checked out, so a fully merged gh-stack store would otherwise stay "live".
360pub(crate) fn has_live_branches(repo: &Repository) -> bool {
361    match read_metadata(repo) {
362        Ok(meta) => meta
363            .parents
364            .iter()
365            .any(|(b, m)| !m.merged && crate::resolve::branch_exists(repo, b)),
366        Err(e) => {
367            log::debug!("gh-stack: has_live_branches: read_metadata failed: {e}");
368            false
369        }
370    }
371}
372
373/// Return all gh-stack stacks, one per connected component, ghost branches PRUNED.
374pub(crate) fn enumerate_stacks(repo: &Repository) -> Result<Vec<Stack>, StackError> {
375    Ok(metadata::enumerate(repo, &read_metadata(repo)?))
376}
377
378/// Get the gh-stack stack for the worktree whose HEAD is `head_branch`, ghost branches
379/// RETAINED (see [`metadata::current`]).
380pub(crate) fn current_stack(
381    repo: &Repository,
382    head_branch: &str,
383) -> Result<Option<Stack>, StackError> {
384    Ok(metadata::current(&read_metadata(repo)?, head_branch))
385}
386
387// ── Linking worktrees to the canonical file ─────────────────────────────────────────────
388
389/// RAII guard holding a gh-stack lock file's `flock`. Released on drop.
390#[cfg(unix)]
391struct LockGuard(std::fs::File);
392
393#[cfg(unix)]
394impl Drop for LockGuard {
395    fn drop(&mut self) {
396        // SAFETY: `self.0` is a valid, open file descriptor for the whole guard lifetime.
397        unsafe {
398            libc::flock(self.0.as_raw_fd(), libc::LOCK_UN);
399        }
400    }
401}
402
403#[cfg(not(unix))]
404struct LockGuard;
405
406const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
407const LOCK_RETRY_DELAY: Duration = Duration::from_millis(100);
408
409/// Take the `flock(LOCK_EX | LOCK_NB)` on `lock_path`, retried every 100ms up to 5s, the same
410/// policy gh-stack uses for both of its locks. The file is created if missing and never
411/// deleted, matching upstream. A no-op guard on non-unix targets, mirroring `graphite.rs`'s
412/// `#[cfg(not(unix))]` fallback.
413#[cfg(unix)]
414fn flock_path(lock_path: PathBuf) -> Result<LockGuard, StackError> {
415    let file = std::fs::OpenOptions::new()
416        .create(true)
417        .write(true)
418        .truncate(false) // lock file's contents (if any) are irrelevant; never clobber them
419        .open(&lock_path)
420        .map_err(|e| StackError::GhStackWriteFailed {
421            path: lock_path.clone(),
422            message: e.to_string(),
423        })?;
424
425    let deadline = std::time::Instant::now() + LOCK_TIMEOUT;
426    loop {
427        let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
428        if ret == 0 {
429            return Ok(LockGuard(file));
430        }
431        let err = std::io::Error::last_os_error();
432        if err.raw_os_error() != Some(libc::EWOULDBLOCK) || std::time::Instant::now() >= deadline {
433            return Err(StackError::GhStackLocked { path: lock_path });
434        }
435        std::thread::sleep(LOCK_RETRY_DELAY);
436    }
437}
438
439#[cfg(not(unix))]
440fn flock_path(_lock_path: PathBuf) -> Result<LockGuard, StackError> {
441    Ok(LockGuard)
442}
443
444/// Take `<common-dir>/gh-stack-operation.lock`, which gh-stack >=0.2 holds for a whole command
445/// and acquires before loading state and before the catalog lock. Take it first, always, so
446/// the two processes can't deadlock on opposite orders.
447fn lock_operation(repo: &Repository) -> Result<LockGuard, StackError> {
448    flock_path(repo.commondir().join("gh-stack-operation.lock"))
449}
450
451/// Take `<common-dir>/gh-stack.lock`, the same catalog lock gh-stack takes, so a concurrent
452/// `gh stack` run is excluded while [`register_branch`] rewrites the catalog. Always taken
453/// after [`lock_operation`].
454fn lock_canonical(repo: &Repository) -> Result<LockGuard, StackError> {
455    flock_path(repo.commondir().join("gh-stack.lock"))
456}
457
458/// Read `path` as a whole raw `Value` (no [`GhStackEntry`] parsing, so every top-level field —
459/// `repository`, `id`, `pullRequest`, anything a future gh-stack adds — survives), rejecting
460/// `schemaVersion > 1`. `Ok(None)` for a missing file. A single attempt, no retries: called
461/// only under the locks during `doctor --fix` or [`register_branch`], not on the hot
462/// read path [`read_doc`] serves.
463fn read_raw_doc(path: &Path) -> Result<Option<Value>, StackError> {
464    match std::fs::read(path) {
465        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
466        Err(e) => Err(StackError::GhStackParseFailed {
467            path: path.to_path_buf(),
468            message: e.to_string(),
469        }),
470        Ok(bytes) => {
471            let value: Value =
472                serde_json::from_slice(&bytes).map_err(|e| StackError::GhStackParseFailed {
473                    path: path.to_path_buf(),
474                    message: e.to_string(),
475                })?;
476            let version = value
477                .get("schemaVersion")
478                .and_then(|v| v.as_u64())
479                .filter(|&v| v != 0)
480                .unwrap_or(1);
481            if version > 1 {
482                return Err(StackError::GhStackSchemaUnsupported {
483                    path: path.to_path_buf(),
484                    version,
485                });
486            }
487            Ok(Some(value))
488        }
489    }
490}
491
492/// Read `path`'s `stacks[]` array as raw `Value`s (no [`GhStackEntry`] parsing, so `id` and
493/// `pullRequest` survive). Missing file, or a file with no `stacks` array, is an empty vec.
494fn read_raw_stacks(path: &Path) -> Result<Vec<Value>, StackError> {
495    Ok(read_raw_doc(path)?
496        .and_then(|doc| doc.get("stacks").and_then(|v| v.as_array()).cloned())
497        .unwrap_or_default())
498}
499
500// ── Registering new branches (write path) ───────────────────────────────────────────────
501
502/// Resolve `name`'s local branch tip. `workon new` has already created both `branch` and
503/// (normally) `base_branch` by the time [`register_branch`] runs, so failure here means
504/// something is badly wrong rather than an expected condition — reported as
505/// [`StackError::GhStackWriteFailed`] since there's no more specific variant for "the thing
506/// we were asked to register doesn't resolve to a commit".
507fn branch_tip(repo: &Repository, name: &str) -> Result<git2::Oid, StackError> {
508    let branch = repo
509        .find_branch(name, git2::BranchType::Local)
510        .map_err(|e| StackError::GhStackWriteFailed {
511            path: canonical_path(repo),
512            message: format!("branch '{name}' not found: {e}"),
513        })?;
514    branch
515        .get()
516        .target()
517        .ok_or_else(|| StackError::GhStackWriteFailed {
518            path: canonical_path(repo),
519            message: format!("branch '{name}' has no target (unborn?)"),
520        })
521}
522
523/// Find the index in `stacks` (a `stacks[]` array of raw `Value`s) whose stack currently ends
524/// at `base_branch`: either its last `branches` element is `base_branch`, or it has no
525/// `branches` yet and its `trunk.branch` is `base_branch`. First match wins when more than
526/// one qualifies — `doctor`'s `GhStackDivergentStacks` check is what flags that situation,
527/// not this function.
528fn select_target_index(stacks: &[Value], base_branch: &str) -> Option<usize> {
529    stacks
530        .iter()
531        .position(|stack| {
532            stack
533                .get("branches")
534                .and_then(|b| b.as_array())
535                .and_then(|arr| arr.last())
536                .and_then(|b| b.get("branch"))
537                .and_then(|v| v.as_str())
538                == Some(base_branch)
539        })
540        .or_else(|| {
541            stacks.iter().position(|stack| {
542                let branches_empty = stack
543                    .get("branches")
544                    .and_then(|b| b.as_array())
545                    .map(|arr| arr.is_empty())
546                    .unwrap_or(true);
547                branches_empty
548                    && stack
549                        .get("trunk")
550                        .and_then(|t| t.get("branch"))
551                        .and_then(|v| v.as_str())
552                        == Some(base_branch)
553            })
554        })
555}
556
557/// Build the full replacement document (as pretty-printed bytes) for `register_branch`,
558/// given the raw bytes currently on disk (`existing`, possibly empty for "file doesn't exist
559/// yet"). Round-trips through `serde_json::Value` rather than a typed struct so `id`,
560/// `pullRequest`, and any other field on untouched `stacks[]` entries survive unchanged —
561/// only the target stack's `branches` array gains one new, minimal entry.
562fn plan_registered_doc(
563    existing: &[u8],
564    branch: &str,
565    base_branch: &str,
566    base: &str,
567    head: &str,
568    canonical: &Path,
569) -> Result<Vec<u8>, StackError> {
570    let mut doc: Value = if existing.is_empty() {
571        serde_json::json!({ "schemaVersion": 1, "stacks": [] })
572    } else {
573        serde_json::from_slice(existing).map_err(|e| StackError::GhStackParseFailed {
574            path: canonical.to_path_buf(),
575            message: e.to_string(),
576        })?
577    };
578
579    let version = doc
580        .get("schemaVersion")
581        .and_then(|v| v.as_u64())
582        .filter(|&v| v != 0)
583        .unwrap_or(1);
584    if version > 1 {
585        return Err(StackError::GhStackSchemaUnsupported {
586            path: canonical.to_path_buf(),
587            version,
588        });
589    }
590
591    let stacks = doc
592        .get_mut("stacks")
593        .and_then(|v| v.as_array_mut())
594        .ok_or_else(|| StackError::GhStackNoStackForBase {
595            base: base_branch.to_string(),
596        })?;
597
598    let idx = select_target_index(stacks, base_branch).ok_or_else(|| {
599        StackError::GhStackNoStackForBase {
600            base: base_branch.to_string(),
601        }
602    })?;
603
604    // `pullRequest` is deliberately omitted, matching upstream's `omitempty` on a fresh entry.
605    let new_entry = serde_json::json!({ "branch": branch, "head": head, "base": base });
606    match stacks[idx]
607        .get_mut("branches")
608        .and_then(|v| v.as_array_mut())
609    {
610        Some(arr) => arr.push(new_entry),
611        None => stacks[idx]["branches"] = serde_json::json!([new_entry]),
612    }
613
614    serde_json::to_vec_pretty(&doc).map_err(|e| StackError::GhStackWriteFailed {
615        path: canonical.to_path_buf(),
616        message: e.to_string(),
617    })
618}
619
620/// Write `bytes` to `canonical` via `<common-dir>/gh-stack.tmp` then `fs::rename`, mode 0644.
621/// Atomic, unlike upstream's `os.WriteFile` — always call this with `canonical` itself
622/// ([`canonical_path`]'s return value), never a worktree's symlinked admin-dir path: renaming
623/// onto a symlink replaces the link with a real file instead of updating what it points to,
624/// silently detaching that worktree from the shared store.
625fn write_canonical_atomic(canonical: &Path, bytes: &[u8]) -> Result<(), StackError> {
626    let tmp_path = canonical.with_extension("tmp");
627    std::fs::write(&tmp_path, bytes).map_err(|e| StackError::GhStackWriteFailed {
628        path: tmp_path.clone(),
629        message: e.to_string(),
630    })?;
631
632    #[cfg(unix)]
633    {
634        use std::os::unix::fs::PermissionsExt;
635        std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o644)).map_err(
636            |e| StackError::GhStackWriteFailed {
637                path: tmp_path.clone(),
638                message: e.to_string(),
639            },
640        )?;
641    }
642
643    std::fs::rename(&tmp_path, canonical).map_err(|e| StackError::GhStackWriteFailed {
644        path: canonical.to_path_buf(),
645        message: e.to_string(),
646    })
647}
648
649/// Append `branch` to the canonical file's stack that currently ends at `base_branch`.
650/// Round-trips through `serde_json::Value` — a typed struct would silently drop `id`,
651/// `pullRequest`, and any field a future gh-stack adds.
652///
653/// `base` is `repo.merge_base(base_branch's tip, branch's tip)` — equal to `base_branch`'s
654/// tip in the normal case, but still correct if `base_branch` moved between worktree creation
655/// and this call. Guarded by [`lock_operation`] then [`lock_canonical`] (upstream's order), so
656/// a concurrent `gh stack` run in any worktree is genuinely excluded. Once both are held, a
657/// present `<common-dir>/gh-stack-migration` journal aborts with
658/// [`StackError::GhStackMigrationPending`]: gh-stack >=0.2 refuses catalog writes mid-migration
659/// and so does workon. Locks release in reverse order on drop.
660///
661/// The file is read only after both locks are held. No lock-respecting writer (every `gh stack`
662/// invocation, and every other `git-workon` call into this module) can be mid-write once the
663/// lock is ours, so a read-under-lock always sees a complete file — there is nothing to
664/// compare-and-swap against. A pre-lock read would risk observing upstream's non-atomic
665/// `os.WriteFile` mid-truncation and handing a partial prefix to [`plan_registered_doc`],
666/// which is exactly the failure this ordering avoids.
667pub fn register_branch(
668    repo: &Repository,
669    branch: &str,
670    base_branch: &str,
671) -> Result<(), StackError> {
672    let head = branch_tip(repo, branch)?;
673    let base_tip = branch_tip(repo, base_branch)?;
674    let base = repo.merge_base(base_tip, head).unwrap_or(base_tip);
675
676    let canonical = canonical_path(repo);
677    let _operation_lock = lock_operation(repo)?;
678    let _lock = lock_canonical(repo)?;
679
680    let journal = repo.commondir().join("gh-stack-migration");
681    if journal.exists() {
682        return Err(StackError::GhStackMigrationPending { path: journal });
683    }
684
685    let existing = std::fs::read(&canonical).unwrap_or_default();
686    let new_bytes = match plan_registered_doc(
687        &existing,
688        branch,
689        base_branch,
690        &base.to_string(),
691        &head.to_string(),
692        &canonical,
693    ) {
694        // `read_metadata` (used by `list`/`find`) unions canonical with `unlinked_files`, but
695        // this function reads canonical alone — deliberately, since it must never write
696        // through a worktree symlink (see `write_canonical_atomic`'s docs). So the spec's
697        // accepted chicken-and-egg case (someone runs `gh stack init` inside a worktree before
698        // ever running `doctor --fix`) reads fine everywhere but fails registration here with
699        // a message that looks identical to "no such stack at all". Point at the fix instead.
700        Err(StackError::GhStackNoStackForBase { base }) if !unlinked_files(repo).is_empty() => {
701            return Err(StackError::GhStackStackInUnlinkedWorktree { base });
702        }
703        Err(e) => return Err(e),
704        Ok(bytes) => bytes,
705    };
706
707    write_canonical_atomic(&canonical, &new_bytes)
708}
709
710// ── `doctor` support ─────────────────────────────────────────────────────────────────────
711
712/// Upstream's recovery records (`gh-stack-rebase-state`, `gh-stack-modify-state`). One in an admin
713/// dir that also holds a legacy catalog blocks upstream's migration (`MigrationBlockedError`).
714const RECOVERY_FILES: [&str; 2] = ["gh-stack-rebase-state", "gh-stack-modify-state"];
715
716/// What a worktree's admin dir holds that gh-stack >=0.2 cares about, for `doctor`.
717#[derive(Debug, Clone, PartialEq, Eq, Default)]
718pub struct WorktreeGhStackState {
719    /// `gh-stack` or `gh-stack.lock` is a symlink — what workon planted before gh-stack 0.2.
720    /// Upstream's `readMigrationFile` rejects it, so every `gh stack` command fails.
721    pub symlinked: bool,
722    /// `gh-stack` is a real file: a legacy per-worktree catalog upstream migrates on its next run.
723    pub legacy_file: bool,
724    /// Recovery records present in the admin dir, by file name.
725    pub recovery_files: Vec<&'static str>,
726}
727
728/// Inspect `worktree_name`'s admin dir with `symlink_metadata`, so a symlink is never followed.
729/// A worktree with no `gh-stack*` entries yields the default (all-clear) state.
730pub(crate) fn worktree_state(repo: &Repository, worktree_name: &str) -> WorktreeGhStackState {
731    let admin_dir = repo.commondir().join("worktrees").join(worktree_name);
732    let file_type = |name: &str| {
733        std::fs::symlink_metadata(admin_dir.join(name))
734            .ok()
735            .map(|m| m.file_type())
736    };
737
738    WorktreeGhStackState {
739        symlinked: ["gh-stack", "gh-stack.lock"]
740            .iter()
741            .any(|name| file_type(name).is_some_and(|t| t.is_symlink())),
742        legacy_file: file_type("gh-stack").is_some_and(|t| t.is_file()),
743        recovery_files: RECOVERY_FILES
744            .into_iter()
745            .filter(|name| file_type(name).is_some())
746            .collect(),
747    }
748}
749
750/// Remove the `gh-stack` and `gh-stack.lock` symlinks in `worktree_name`'s admin dir, returning
751/// the names removed. Anything that is not a symlink (a legacy catalog upstream still has to
752/// migrate, a real lock file) is left alone, and `symlink_metadata` keeps a symlink from being
753/// followed to its target.
754pub(crate) fn unlink_worktree(
755    repo: &Repository,
756    worktree_name: &str,
757) -> crate::error::Result<Vec<&'static str>> {
758    let admin_dir = repo.commondir().join("worktrees").join(worktree_name);
759    let mut removed = Vec::new();
760    for name in ["gh-stack", "gh-stack.lock"] {
761        let path = admin_dir.join(name);
762        let is_symlink = std::fs::symlink_metadata(&path)
763            .map(|m| m.file_type().is_symlink())
764            .unwrap_or(false);
765        if is_symlink {
766            std::fs::remove_file(&path)?;
767            removed.push(name);
768        }
769    }
770    Ok(removed)
771}
772
773/// Files (canonical + [`unlinked_files`]) that exist but fail to parse, or whose
774/// `schemaVersion` is unsupported — for `doctor`'s `GhStackFileUnreadable` check.
775///
776/// Unlike [`read_doc`], this is a single-attempt read: `doctor` is a point-in-time health
777/// check, not the hot read path a concurrent `gh stack` write races against, so there is no
778/// truncated-read tolerance to preserve here — a transient mid-write read just gets reported
779/// and re-checked on the next `doctor` run.
780pub(crate) fn readability_errors(repo: &Repository) -> Vec<(PathBuf, StackError)> {
781    let mut sources = vec![canonical_path(repo)];
782    sources.extend(unlinked_files(repo));
783
784    sources
785        .into_iter()
786        .filter(|path| path.exists())
787        .filter_map(|path| match read_raw_stacks(&path) {
788            Ok(_) => None,
789            Err(e) => Some((path, e)),
790        })
791        .collect()
792}
793
794/// Stack numbers that appear, with genuinely different content, in more than one gh-stack
795/// source — only possible when the degraded union read (see the module docs) actually combines
796/// canonical with an unlinked worktree file. For `doctor`'s `GhStackDivergentStacks` check.
797///
798/// Each number is counted at most once *per source*, so two stacks numbered 1 inside a single
799/// file don't get flagged as spanning "more than one gh-stack source" — that phrase means
800/// files, not array entries. And a number is only reported when its sources disagree: an
801/// unlinked worktree file holding a byte-identical copy of a canonical stack (the common state
802/// right after someone copies a worktree) is compared by content — at minimum its branch list —
803/// so an identical copy is not reported.
804pub(crate) fn divergent_stack_numbers(repo: &Repository) -> Vec<u64> {
805    let mut sources = vec![canonical_path(repo)];
806    sources.extend(unlinked_files(repo));
807
808    // number -> one branch-list signature per source that contains it (deduped within that
809    // source, so a file with two same-numbered stacks contributes one signature, not two).
810    let mut signatures_by_number: HashMap<u64, Vec<Vec<String>>> = HashMap::new();
811    for path in &sources {
812        if let Ok(Some(entries)) = read_doc(path) {
813            let mut numbers_in_this_source: HashSet<u64> = HashSet::new();
814            for entry in entries {
815                if entry.number != 0 && numbers_in_this_source.insert(entry.number) {
816                    let branches: Vec<String> =
817                        entry.branches.iter().map(|b| b.branch.clone()).collect();
818                    signatures_by_number
819                        .entry(entry.number)
820                        .or_default()
821                        .push(branches);
822                }
823            }
824        }
825    }
826
827    let mut divergent: Vec<u64> = signatures_by_number
828        .into_iter()
829        .filter(|(_, signatures)| signatures.iter().any(|s| s != &signatures[0]))
830        .map(|(number, _)| number)
831        .collect();
832    divergent.sort_unstable();
833    divergent
834}
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839    use git_workon_fixture::prelude::*;
840
841    #[test]
842    fn reads_linear_stack_from_canonical() {
843        let fixture = FixtureBuilder::new()
844            .bare(true)
845            .default_branch("main")
846            .worktree("main")
847            .gh_stack(None, 12, "main", &["feat-a", "feat-b"])
848            .build()
849            .unwrap();
850        let repo = fixture.repo().unwrap();
851
852        let meta = read_metadata(repo).unwrap();
853        assert_eq!(meta.trunks, vec!["main".to_string()]);
854        assert_eq!(meta.parents["feat-a"].parent, "main");
855        assert_eq!(meta.parents["feat-b"].parent, "feat-a");
856        assert_eq!(meta.stack_numbers["feat-a"], 12);
857        assert_eq!(meta.stack_numbers["feat-b"], 12);
858
859        let stacks = enumerate_stacks(repo).unwrap();
860        assert_eq!(stacks.len(), 1);
861        assert_eq!(stacks[0].number, Some(12));
862        assert_eq!(stacks[0].diffs, vec!["feat-a", "feat-b"]);
863    }
864
865    #[test]
866    fn merged_true_is_read_from_pull_request() {
867        let fixture = FixtureBuilder::new()
868            .bare(true)
869            .default_branch("main")
870            .worktree("main")
871            .branch("feat-a")
872            .branch("feat-b")
873            .raw_gh_stack(
874                None,
875                br#"{"schemaVersion": 1, "stacks": [{"number": 1, "trunk": {"branch": "main", "head": "", "base": ""}, "branches": [{"branch": "feat-a", "head": "", "base": "", "pullRequest": {"number": 1, "merged": true}}, {"branch": "feat-b", "head": "", "base": "", "pullRequest": null}]}]}"#.to_vec(),
876            )
877            .build()
878            .unwrap();
879        let repo = fixture.repo().unwrap();
880
881        let meta = read_metadata(repo).unwrap();
882        assert!(meta.parents["feat-a"].merged);
883        assert!(!meta.parents["feat-b"].merged, "sibling must stay unmerged");
884    }
885
886    #[test]
887    fn missing_pull_request_reads_merged_false() {
888        let fixture = FixtureBuilder::new()
889            .bare(true)
890            .default_branch("main")
891            .worktree("main")
892            .branch("feat-a")
893            .raw_gh_stack(
894                None,
895                br#"{"schemaVersion": 1, "stacks": [{"number": 1, "trunk": {"branch": "main", "head": "", "base": ""}, "branches": [{"branch": "feat-a", "head": "", "base": ""}]}]}"#.to_vec(),
896            )
897            .build()
898            .unwrap();
899        let repo = fixture.repo().unwrap();
900
901        let meta = read_metadata(repo).unwrap();
902        assert!(!meta.parents["feat-a"].merged);
903    }
904
905    #[test]
906    fn null_pull_request_reads_merged_false() {
907        let fixture = FixtureBuilder::new()
908            .bare(true)
909            .default_branch("main")
910            .worktree("main")
911            .branch("feat-a")
912            .raw_gh_stack(
913                None,
914                br#"{"schemaVersion": 1, "stacks": [{"number": 1, "trunk": {"branch": "main", "head": "", "base": ""}, "branches": [{"branch": "feat-a", "head": "", "base": "", "pullRequest": null}]}]}"#.to_vec(),
915            )
916            .build()
917            .unwrap();
918        let repo = fixture.repo().unwrap();
919
920        let meta = read_metadata(repo).unwrap();
921        assert!(!meta.parents["feat-a"].merged);
922    }
923
924    #[test]
925    fn ghost_retained_by_current_stack_and_pruned_by_enumerate() {
926        let fixture = FixtureBuilder::new()
927            .bare(true)
928            .default_branch("main")
929            .worktree("main")
930            .gh_stack(None, 5, "main", &["feat-a"])
931            .gh_stack_ghost_branch(None, 5, "feat-b")
932            .build()
933            .unwrap();
934        let repo = fixture.repo().unwrap();
935
936        // current_stack retains the ghost when walking from a live descendant... but feat-b
937        // has no ref, so retrieve current_stack from feat-a (the live branch) instead, which
938        // must still see feat-b was never linked as a child in enumerate's pruned output.
939        let current = current_stack(repo, "feat-a").unwrap().expect("tracked");
940        assert!(current.diffs.contains(&"feat-a".to_string()));
941
942        let enumerated = enumerate_stacks(repo).unwrap();
943        assert_eq!(enumerated.len(), 1);
944        assert!(!enumerated[0].diffs.contains(&"feat-b".to_string()));
945        assert!(enumerated[0].diffs.contains(&"feat-a".to_string()));
946    }
947
948    #[test]
949    fn truncated_file_is_skipped() {
950        let fixture = FixtureBuilder::new()
951            .bare(true)
952            .default_branch("main")
953            .worktree("main")
954            .raw_gh_stack(None, b"{\"schemaVersion\": 1, \"stacks\": [".to_vec())
955            .build()
956            .unwrap();
957        let repo = fixture.repo().unwrap();
958
959        let meta = read_metadata(repo).unwrap();
960        assert!(meta.trunks.is_empty());
961        assert!(meta.parents.is_empty());
962    }
963
964    #[test]
965    fn schema_version_2_is_a_hard_error() {
966        let fixture = FixtureBuilder::new()
967            .bare(true)
968            .default_branch("main")
969            .worktree("main")
970            .raw_gh_stack(None, br#"{"schemaVersion": 2, "stacks": []}"#.to_vec())
971            .build()
972            .unwrap();
973        let repo = fixture.repo().unwrap();
974
975        match read_metadata(repo) {
976            Err(StackError::GhStackSchemaUnsupported { version: 2, .. }) => {}
977            Err(e) => panic!("expected GhStackSchemaUnsupported{{version: 2}}, got {e:?}"),
978            Ok(_) => panic!("expected GhStackSchemaUnsupported{{version: 2}}, got Ok"),
979        }
980    }
981
982    #[test]
983    fn missing_schema_version_defaults_to_1() {
984        // The module doc claims a missing `schemaVersion` is treated as `1`, matching Go's
985        // zero-value behavior for an unset int field. No prior test omitted the field.
986        let fixture = FixtureBuilder::new()
987            .bare(true)
988            .default_branch("main")
989            .worktree("main")
990            .branch("feat-a")
991            .raw_gh_stack(
992                None,
993                br#"{"stacks": [{"number": 1, "trunk": {"branch": "main", "head": "", "base": ""}, "branches": [{"branch": "feat-a", "head": "", "base": ""}]}]}"#.to_vec(),
994            )
995            .build()
996            .unwrap();
997        let repo = fixture.repo().unwrap();
998
999        let meta = read_metadata(repo).unwrap();
1000        assert_eq!(meta.parents["feat-a"].parent, "main");
1001        assert_eq!(meta.stack_numbers["feat-a"], 1);
1002    }
1003
1004    #[test]
1005    fn schema_version_0_defaults_to_1() {
1006        // Same claim, explicit `schemaVersion: 0` — Go's own zero value for the field, and
1007        // distinct from "the field is absent" (missing_schema_version_defaults_to_1 above),
1008        // since the two arrive through different branches of `.filter(|&v| v != 0)`.
1009        let fixture = FixtureBuilder::new()
1010            .bare(true)
1011            .default_branch("main")
1012            .worktree("main")
1013            .branch("feat-a")
1014            .raw_gh_stack(
1015                None,
1016                br#"{"schemaVersion": 0, "stacks": [{"number": 1, "trunk": {"branch": "main", "head": "", "base": ""}, "branches": [{"branch": "feat-a", "head": "", "base": ""}]}]}"#.to_vec(),
1017            )
1018            .build()
1019            .unwrap();
1020        let repo = fixture.repo().unwrap();
1021
1022        let meta = read_metadata(repo).unwrap();
1023        assert_eq!(meta.parents["feat-a"].parent, "main");
1024        assert_eq!(meta.stack_numbers["feat-a"], 1);
1025    }
1026
1027    #[test]
1028    fn needs_restack_true_when_base_differs_from_parent_live_tip() {
1029        let fixture = FixtureBuilder::new()
1030            .bare(true)
1031            .default_branch("main")
1032            .worktree("main")
1033            .gh_stack_at(
1034                None,
1035                1,
1036                "main",
1037                &[("feat-a", "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")],
1038            )
1039            .build()
1040            .unwrap();
1041        let repo = fixture.repo().unwrap();
1042
1043        let meta = read_metadata(repo).unwrap();
1044        let entry = meta.parents.get("feat-a").unwrap();
1045        assert_eq!(
1046            entry.parent_revision.as_deref(),
1047            Some("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
1048        );
1049        let main_tip = repo
1050            .find_branch("main", git2::BranchType::Local)
1051            .unwrap()
1052            .get()
1053            .target()
1054            .unwrap();
1055        assert_ne!(
1056            entry.parent_revision.as_deref(),
1057            Some(main_tip.to_string().as_str())
1058        );
1059    }
1060
1061    #[test]
1062    fn degraded_union_pulls_in_unlinked_worktree_file() {
1063        let fixture = FixtureBuilder::new()
1064            .bare(true)
1065            .default_branch("main")
1066            .worktree("main")
1067            .worktree("feat-a")
1068            .gh_stack(Some("feat-a"), 9, "main", &["feat-a"])
1069            .build()
1070            .unwrap();
1071        let repo = fixture.repo().unwrap();
1072
1073        let meta = read_metadata(repo).unwrap();
1074        assert_eq!(meta.parents["feat-a"].parent, "main");
1075        assert_eq!(meta.stack_numbers["feat-a"], 9);
1076    }
1077
1078    #[test]
1079    fn degraded_union_first_wins_on_disagreeing_unlinked_files() {
1080        // Two worktrees each hold their own unlinked file, both claiming stack number 1 for
1081        // a different branch set. Canonical is empty, so both are unioned; directory-name
1082        // order ("feat-a" < "feat-b") makes feat-a's file win the number-1 identity.
1083        let fixture = FixtureBuilder::new()
1084            .bare(true)
1085            .default_branch("main")
1086            .worktree("main")
1087            .worktree("feat-a")
1088            .worktree("feat-b")
1089            .gh_stack(Some("feat-a"), 1, "main", &["feat-a"])
1090            .gh_stack(Some("feat-b"), 1, "main", &["feat-b"])
1091            .build()
1092            .unwrap();
1093        let repo = fixture.repo().unwrap();
1094
1095        let meta = read_metadata(repo).unwrap();
1096        assert!(meta.parents.contains_key("feat-a"));
1097        assert!(!meta.parents.contains_key("feat-b"));
1098    }
1099
1100    // ── divergent_stack_numbers ─────────────────────────────────────────────────
1101
1102    #[test]
1103    fn two_numbered_stacks_in_one_file_are_not_divergent() {
1104        // Regression test for finding H(a): divergent_stack_numbers used to increment a
1105        // global per-number counter across all sources, so two *different* stacks both
1106        // numbered 1 inside the SAME file tripped count > 1, contradicting the doc comment
1107        // "appear in more than one gh-stack source" (source means file, not array entry).
1108        let fixture = FixtureBuilder::new()
1109            .bare(true)
1110            .default_branch("main")
1111            .branch("other-trunk")
1112            .worktree("main")
1113            .gh_stack(None, 1, "main", &["feat-a"])
1114            .gh_stack(None, 1, "other-trunk", &["feat-b"])
1115            .build()
1116            .unwrap();
1117        let repo = fixture.repo().unwrap();
1118
1119        assert!(divergent_stack_numbers(repo).is_empty());
1120    }
1121
1122    #[test]
1123    fn identical_copy_across_canonical_and_unlinked_is_not_divergent() {
1124        // Regression test for finding H(b): an unlinked worktree file holding a byte-identical
1125        // copy of a canonical stack is the common state right after someone copies a
1126        // worktree, not a real divergence, so it must not be flagged.
1127        let fixture = FixtureBuilder::new()
1128            .bare(true)
1129            .default_branch("main")
1130            .worktree("main")
1131            .worktree("feat-a")
1132            .gh_stack(None, 4, "main", &["feat-a"])
1133            .gh_stack(Some("feat-a"), 4, "main", &["feat-a"])
1134            .build()
1135            .unwrap();
1136        let repo = fixture.repo().unwrap();
1137
1138        assert!(divergent_stack_numbers(repo).is_empty());
1139    }
1140
1141    #[test]
1142    fn genuinely_differing_copy_across_sources_is_divergent() {
1143        let fixture = FixtureBuilder::new()
1144            .bare(true)
1145            .default_branch("main")
1146            .worktree("main")
1147            .worktree("feat-a")
1148            .branch("feat-b")
1149            .gh_stack(None, 4, "main", &["feat-a"])
1150            .gh_stack(Some("feat-a"), 4, "main", &["feat-b"])
1151            .build()
1152            .unwrap();
1153        let repo = fixture.repo().unwrap();
1154
1155        assert_eq!(divergent_stack_numbers(repo), vec![4]);
1156    }
1157
1158    #[test]
1159    fn branch_spanning_two_stacks_keeps_the_first_stacks_parent_and_number() {
1160        // Regression test for finding E: read_metadata's flattening loop deduped `trunks`
1161        // first-wins but wrote `parents`/`stack_numbers` last-wins, contradicting the module
1162        // doc's "first wins wholesale" and the spec's "first-seen wins, doctor flags it". Two
1163        // canonical stacks, both listing "shared" — stack 1 comes first in file order, so its
1164        // parent ("main") and number (1) must stick even though stack 2 ("other-trunk", 2) is
1165        // read afterward.
1166        let fixture = FixtureBuilder::new()
1167            .bare(true)
1168            .default_branch("main")
1169            .branch("other-trunk")
1170            .worktree("main")
1171            .gh_stack(None, 1, "main", &["shared"])
1172            .gh_stack(None, 2, "other-trunk", &["shared"])
1173            .build()
1174            .unwrap();
1175        let repo = fixture.repo().unwrap();
1176
1177        let meta = read_metadata(repo).unwrap();
1178        assert_eq!(meta.parents["shared"].parent, "main");
1179        assert_eq!(meta.stack_numbers["shared"], 1);
1180    }
1181
1182    #[test]
1183    fn worktree_state_is_clear_for_a_worktree_with_no_gh_stack_entries() {
1184        let fixture = FixtureBuilder::new()
1185            .bare(true)
1186            .default_branch("main")
1187            .worktree("main")
1188            .worktree("feat-a")
1189            .gh_stack(None, 1, "main", &["feat-a"])
1190            .build()
1191            .unwrap();
1192        let repo = fixture.repo().unwrap();
1193
1194        assert_eq!(
1195            worktree_state(repo, "feat-a"),
1196            WorktreeGhStackState::default()
1197        );
1198    }
1199
1200    #[test]
1201    fn worktree_state_reports_symlinks_and_unlink_removes_only_them() {
1202        let fixture = FixtureBuilder::new()
1203            .bare(true)
1204            .default_branch("main")
1205            .worktree("main")
1206            .worktree("feat-a")
1207            .gh_stack(None, 1, "main", &["feat-a"])
1208            .gh_stack_symlinked("feat-a")
1209            .build()
1210            .unwrap();
1211        let repo = fixture.repo().unwrap();
1212
1213        let state = worktree_state(repo, "feat-a");
1214        assert!(state.symlinked);
1215        assert!(!state.legacy_file);
1216
1217        let removed = unlink_worktree(repo, "feat-a").unwrap();
1218        assert_eq!(removed, ["gh-stack", "gh-stack.lock"]);
1219        assert_eq!(
1220            worktree_state(repo, "feat-a"),
1221            WorktreeGhStackState::default()
1222        );
1223        // The canonical catalog the links pointed at survives.
1224        assert!(canonical_path(repo).is_file());
1225    }
1226
1227    #[test]
1228    fn unlink_worktree_leaves_a_real_legacy_file_alone() {
1229        let fixture = FixtureBuilder::new()
1230            .bare(true)
1231            .default_branch("main")
1232            .worktree("main")
1233            .worktree("feat-a")
1234            .gh_stack(Some("feat-a"), 2, "main", &["feat-a"])
1235            .build()
1236            .unwrap();
1237        let repo = fixture.repo().unwrap();
1238
1239        let state = worktree_state(repo, "feat-a");
1240        assert!(state.legacy_file);
1241        assert!(!state.symlinked);
1242
1243        assert!(unlink_worktree(repo, "feat-a").unwrap().is_empty());
1244        let file = repo.commondir().join("worktrees/feat-a/gh-stack");
1245        assert!(file.is_file());
1246    }
1247
1248    #[test]
1249    fn worktree_state_reports_recovery_files() {
1250        let fixture = FixtureBuilder::new()
1251            .bare(true)
1252            .default_branch("main")
1253            .worktree("main")
1254            .worktree("feat-a")
1255            .gh_stack_recovery_file("feat-a", "gh-stack-rebase-state")
1256            .build()
1257            .unwrap();
1258        let repo = fixture.repo().unwrap();
1259
1260        assert_eq!(
1261            worktree_state(repo, "feat-a").recovery_files,
1262            ["gh-stack-rebase-state"]
1263        );
1264    }
1265
1266    // ── register_branch ─────────────────────────────────────────────────────────
1267
1268    #[test]
1269    fn register_branch_appends_onto_a_trunk_with_no_branches_yet() {
1270        let fixture = FixtureBuilder::new()
1271            .bare(true)
1272            .default_branch("main")
1273            .worktree("main")
1274            .branch("feat-a")
1275            .gh_stack(None, 1, "main", &[])
1276            .build()
1277            .unwrap();
1278        let repo = fixture.repo().unwrap();
1279
1280        register_branch(repo, "feat-a", "main").unwrap();
1281
1282        repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-a", 0));
1283        let head_oid = repo
1284            .find_branch("feat-a", git2::BranchType::Local)
1285            .unwrap()
1286            .get()
1287            .target()
1288            .unwrap();
1289        repo.assert(predicate::repo::gh_stack_branch_base(
1290            None,
1291            "feat-a",
1292            head_oid.to_string(),
1293        ));
1294    }
1295
1296    #[test]
1297    fn register_branch_appends_onto_the_top_of_an_existing_stack() {
1298        let fixture = FixtureBuilder::new()
1299            .bare(true)
1300            .default_branch("main")
1301            .worktree("main")
1302            .gh_stack(None, 1, "main", &["feat-a"])
1303            .branch("feat-b")
1304            .build()
1305            .unwrap();
1306        let repo = fixture.repo().unwrap();
1307
1308        register_branch(repo, "feat-b", "feat-a").unwrap();
1309
1310        repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-a", 0));
1311        repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-b", 1));
1312    }
1313
1314    #[test]
1315    fn register_branch_preserves_id_and_pull_request_on_untouched_entries() {
1316        // The existing entry's `id` and its branch's `pullRequest` are fields workon never
1317        // reads. A typed struct would silently drop them on write; the raw-Value round-trip
1318        // must not.
1319        let fixture = FixtureBuilder::new()
1320            .bare(true)
1321            .default_branch("main")
1322            .worktree("main")
1323            .branch("feat-a")
1324            .branch("feat-b")
1325            .raw_gh_stack(
1326                None,
1327                br#"{
1328                    "schemaVersion": 1,
1329                    "stacks": [{
1330                        "id": "stack-abc",
1331                        "number": 3,
1332                        "trunk": { "branch": "main", "head": "", "base": "" },
1333                        "branches": [{
1334                            "branch": "feat-a",
1335                            "head": "0000000000000000000000000000000000000a",
1336                            "base": "0000000000000000000000000000000000000b",
1337                            "pullRequest": { "number": 42, "id": "PR_1", "merged": false }
1338                        }]
1339                    }]
1340                }"#
1341                .to_vec(),
1342            )
1343            .build()
1344            .unwrap();
1345        let repo = fixture.repo().unwrap();
1346
1347        register_branch(repo, "feat-b", "feat-a").unwrap();
1348
1349        repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-a", 0));
1350        repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-b", 1));
1351        repo.assert(predicate::repo::gh_stack_preserves(
1352            None,
1353            "/stacks/0/id",
1354            "stack-abc",
1355        ));
1356        repo.assert(predicate::repo::gh_stack_preserves(
1357            None,
1358            "/stacks/0/branches/0/pullRequest/number",
1359            "42",
1360        ));
1361    }
1362
1363    #[test]
1364    fn register_branch_surfaces_parse_failed_for_truncated_canonical() {
1365        // A truncated canonical file under an uncontended lock is genuine corruption, not a
1366        // mid-write race (a lock-respecting writer can't be mid-write once we hold the lock).
1367        // The read-under-lock ordering means this must deterministically surface
1368        // GhStackParseFailed rather than a stale pre-lock snapshot masking the truncation.
1369        let fixture = FixtureBuilder::new()
1370            .bare(true)
1371            .default_branch("main")
1372            .worktree("main")
1373            .branch("feat-a")
1374            .branch("feat-b")
1375            .gh_stack(None, 1, "main", &["feat-a"])
1376            .raw_gh_stack(None, b"{\"schemaVersion\": 1, \"stacks\": [".to_vec())
1377            .build()
1378            .unwrap();
1379        let repo = fixture.repo().unwrap();
1380
1381        match register_branch(repo, "feat-b", "feat-a") {
1382            Err(StackError::GhStackParseFailed { .. }) => {}
1383            other => panic!("expected GhStackParseFailed, got {other:?}"),
1384        }
1385    }
1386
1387    #[test]
1388    fn register_branch_errors_when_no_stack_ends_at_base() {
1389        // "main" is a real branch, but the only stack's last branch is "feat-a", not "main",
1390        // and its `branches` isn't empty, so "main" doesn't match either target-selection
1391        // rule.
1392        let fixture = FixtureBuilder::new()
1393            .bare(true)
1394            .default_branch("main")
1395            .worktree("main")
1396            .branch("feat-a")
1397            .branch("feat-b")
1398            .gh_stack(None, 1, "main", &["feat-a"])
1399            .build()
1400            .unwrap();
1401        let repo = fixture.repo().unwrap();
1402
1403        match register_branch(repo, "feat-b", "main") {
1404            Err(StackError::GhStackNoStackForBase { base }) => {
1405                assert_eq!(base, "main");
1406            }
1407            other => panic!("expected GhStackNoStackForBase, got {other:?}"),
1408        }
1409    }
1410
1411    #[test]
1412    fn register_branch_points_at_doctor_fix_when_stack_is_unlinked_only() {
1413        // Finding F: the chicken-and-egg case the spec explicitly accepts — `gh stack init`
1414        // run inside a worktree before `doctor --fix` ever migrates it. `read_metadata` unions
1415        // in unlinked_files, so `list`/`find` render the stack correctly, but `register_branch`
1416        // reads canonical alone (it must never write through a worktree symlink) and would
1417        // otherwise report the same generic GhStackNoStackForBase as "no such stack anywhere",
1418        // which is indistinguishable from user error. It must instead point at `doctor --fix`.
1419        let fixture = FixtureBuilder::new()
1420            .bare(true)
1421            .default_branch("main")
1422            .worktree("main")
1423            .worktree("feat-a")
1424            .branch("feat-b")
1425            .gh_stack(Some("feat-a"), 1, "main", &["feat-a"])
1426            .build()
1427            .unwrap();
1428        let repo = fixture.repo().unwrap();
1429
1430        // Sanity: read_metadata (the list/find path) sees the stack fine via the degraded
1431        // union, so the failure below is specific to register_branch's canonical-only read.
1432        let meta = read_metadata(repo).unwrap();
1433        assert_eq!(meta.parents["feat-a"].parent, "main");
1434
1435        match register_branch(repo, "feat-b", "feat-a") {
1436            Err(StackError::GhStackStackInUnlinkedWorktree { base }) => {
1437                assert_eq!(base, "feat-a");
1438            }
1439            other => panic!("expected GhStackStackInUnlinkedWorktree, got {other:?}"),
1440        }
1441    }
1442
1443    #[cfg(unix)]
1444    #[test]
1445    fn register_branch_times_out_when_operation_lock_is_held() {
1446        let fixture = FixtureBuilder::new()
1447            .bare(true)
1448            .default_branch("main")
1449            .worktree("main")
1450            .branch("feat-a")
1451            .gh_stack(None, 1, "main", &[])
1452            .gh_stack_operation_lock_held()
1453            .build()
1454            .unwrap();
1455        let repo = fixture.repo().unwrap();
1456
1457        match register_branch(repo, "feat-a", "main") {
1458            Err(StackError::GhStackLocked { path }) => {
1459                assert_eq!(path, repo.commondir().join("gh-stack-operation.lock"));
1460            }
1461            other => panic!("expected GhStackLocked, got {other:?}"),
1462        }
1463        repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-a", 0).not());
1464    }
1465
1466    #[test]
1467    fn register_branch_refuses_while_migration_journal_exists() {
1468        let fixture = FixtureBuilder::new()
1469            .bare(true)
1470            .default_branch("main")
1471            .worktree("main")
1472            .branch("feat-a")
1473            .gh_stack(None, 1, "main", &[])
1474            .gh_stack_migration_journal()
1475            .build()
1476            .unwrap();
1477        let repo = fixture.repo().unwrap();
1478
1479        match register_branch(repo, "feat-a", "main") {
1480            Err(StackError::GhStackMigrationPending { path }) => {
1481                assert_eq!(path, repo.commondir().join("gh-stack-migration"));
1482            }
1483            other => panic!("expected GhStackMigrationPending, got {other:?}"),
1484        }
1485        repo.assert(predicate::repo::gh_stack_contains_branch(None, "feat-a", 0).not());
1486    }
1487}