Skip to main content

git_stk/commands/
submit.rs

1use std::env;
2use std::path::PathBuf;
3
4use anyhow::{Context, Result, bail};
5use clap::ArgAction;
6use clap_complete::engine::ArgValueCompleter;
7
8use crate::cli::PushMode;
9use crate::commands::Run;
10use crate::completions;
11use crate::providers::{ReviewProvider, ReviewState, detect_review_provider};
12use crate::settings;
13use crate::style;
14use crate::{git, stack};
15
16/// Create or update a remote review request for a branch.
17#[derive(Debug, clap::Args)]
18pub struct Submit {
19    /// Branch to submit (defaults to the current branch).
20    #[arg(add = ArgValueCompleter::new(completions::branch_candidates))]
21    branch: Option<String>,
22    /// Print what would change without creating or updating reviews.
23    #[arg(long, short = 'n', action = ArgAction::SetTrue)]
24    dry_run: bool,
25    /// Submit the whole stack parent-first, from anywhere in it.
26    #[arg(long, conflicts_with = "branch")]
27    stack: bool,
28    /// Submit only the current branch, overriding stk.submitStack.
29    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "stack")]
30    no_stack: bool,
31    /// Submit the stack from its bottom through the current branch only,
32    /// leaving work-in-progress branches above it unsubmitted.
33    #[arg(
34        long,
35        action = ArgAction::SetTrue,
36        conflicts_with_all = ["branch", "stack", "no_stack"],
37    )]
38    downstack: bool,
39    /// Push branches (-u --force-with-lease) before submitting.
40    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_push")]
41    push: bool,
42    /// Do not push branches, overriding stk.pushOnSubmit.
43    #[arg(long, action = ArgAction::SetTrue)]
44    no_push: bool,
45    /// Set the review's title, replacing the branch tip's commit subject.
46    /// Applies to the current or named branch only.
47    #[arg(long, short = 't', value_name = "TEXT")]
48    title: Option<String>,
49    /// Set a description block at the top of the review body; an empty
50    /// string clears it. Applies to the current or named branch only.
51    #[arg(long, short = 'd')]
52    desc: Option<String>,
53    /// Read the description block from a markdown or text file instead of an
54    /// inline string, handy for agent-authored bodies. Incompatible with
55    /// --desc; an empty file clears the block, like `--desc ""`.
56    #[arg(
57        long = "desc-file",
58        value_name = "PATH",
59        value_hint = clap::ValueHint::FilePath,
60        conflicts_with = "desc",
61    )]
62    desc_file: Option<PathBuf>,
63    /// Request reviews from these users or teams on every submitted review
64    /// (comma-separated, or repeat the flag). A leading `@` is optional and
65    /// stripped, so `@foo,@bar` and `foo,bar` mean the same. GitHub/Gitea team
66    /// reviewers use the `org/team` form (`@my-org/backend`).
67    #[arg(long, value_name = "CSV", value_delimiter = ',')]
68    reviewers: Vec<String>,
69    /// Create new reviews as drafts.
70    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_draft")]
71    draft: bool,
72    /// Create new reviews ready for review, overriding stk.submitDraft.
73    #[arg(long, action = ArgAction::SetTrue)]
74    no_draft: bool,
75    /// Mark the submitted branches' existing draft reviews as ready.
76    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "draft")]
77    ready: bool,
78    /// Rebuild each review's stack overview from the live stack plus merged
79    /// history, dropping closed or orphaned rows that drifted in. Stack mode.
80    #[arg(long, action = ArgAction::SetTrue)]
81    rebuild_overview: bool,
82}
83
84impl Run for Submit {
85    fn run(self) -> Result<()> {
86        // Stack mode: --stack forces it on; --no-stack or an explicit branch
87        // forces it off; otherwise stk.submitStack decides.
88        let submit_stack = if self.stack {
89            true
90        } else if self.no_stack || self.branch.is_some() {
91            false
92        } else {
93            settings::bool_setting(settings::SUBMIT_STACK_KEY)?
94        };
95
96        // Draft mode: --draft forces it on, --no-draft off; otherwise
97        // stk.submitDraft decides.
98        let draft = if self.draft {
99            true
100        } else if self.no_draft {
101            false
102        } else {
103            settings::bool_setting(settings::SUBMIT_DRAFT_KEY)?
104        };
105
106        // A file source resolves to the same description string; clap's
107        // conflicts_with guarantees at most one of the two is set.
108        let desc = match self.desc_file {
109            Some(path) => {
110                let path = expand_tilde(path);
111                let raw = std::fs::read_to_string(&path).with_context(|| {
112                    format!("failed to read description file {}", path.display())
113                })?;
114                Some(raw.trim().to_owned())
115            }
116            None => self.desc,
117        };
118
119        // Unlike a description, a title has no "clear it" meaning - every
120        // review must have one - so an empty string is a mistake, not a verb.
121        let title = match self.title {
122            Some(title) if title.trim().is_empty() => bail!("--title cannot be empty"),
123            Some(title) => Some(title.trim().to_owned()),
124            None => None,
125        };
126
127        submit(SubmitOptions {
128            branch: self.branch,
129            submit_stack,
130            downstack: self.downstack,
131            dry_run: self.dry_run,
132            push_mode: PushMode::from_flags(self.push, self.no_push),
133            title,
134            desc,
135            reviewers: normalize_reviewers(&self.reviewers),
136            draft,
137            ready: self.ready,
138            rebuild_overview: self.rebuild_overview,
139        })
140    }
141}
142
143/// Clean a raw `--reviewers` list: trim each entry, drop one optional leading
144/// `@` (so `@foo` and `foo` are the same), discard blanks, and de-duplicate
145/// while preserving order. A team keeps its `org/team` form - only the `@`
146/// prefix is stripped, never the slash. GitHub's Copilot reviewer is the one
147/// login that *needs* its `@` (`@copilot`), so it is preserved (and a bare
148/// `copilot` is canonicalized to it) rather than stripped like a username.
149fn normalize_reviewers(raw: &[String]) -> Vec<String> {
150    let mut reviewers: Vec<String> = Vec::new();
151    for entry in raw {
152        let trimmed = entry.trim();
153        let stripped = trimmed.strip_prefix('@').unwrap_or(trimmed).trim();
154        let name = if stripped.eq_ignore_ascii_case("copilot") {
155            "@copilot"
156        } else {
157            stripped
158        };
159        if name.is_empty() || reviewers.iter().any(|seen| seen == name) {
160            continue;
161        }
162        reviewers.push(name.to_owned());
163    }
164    reviewers
165}
166
167/// The resolved inputs for [`submit`] - one bundle instead of nine positional
168/// arguments. `Submit::run` resolves the flag/config defaults and fills it.
169pub struct SubmitOptions {
170    pub branch: Option<String>,
171    pub submit_stack: bool,
172    pub downstack: bool,
173    pub dry_run: bool,
174    pub push_mode: crate::cli::PushMode,
175    pub title: Option<String>,
176    pub desc: Option<String>,
177    pub reviewers: Vec<String>,
178    pub draft: bool,
179    pub ready: bool,
180    pub rebuild_overview: bool,
181}
182
183/// Expand a leading `~` in a `--desc-file` path to the user's home, since the
184/// path reaches us literally when the shell did not expand it (quoted, or
185/// handed over by a script or agent). Cross-platform: `HOME` on Unix (and
186/// Git Bash/WSL), falling back to `USERPROFILE` on native Windows.
187fn expand_tilde(path: PathBuf) -> PathBuf {
188    let home = env::var_os("HOME")
189        .or_else(|| env::var_os("USERPROFILE"))
190        .map(PathBuf::from);
191    expand_tilde_with(path, home)
192}
193
194fn expand_tilde_with(path: PathBuf, home: Option<PathBuf>) -> PathBuf {
195    let Some(rest) = path.to_str().and_then(|text| text.strip_prefix('~')) else {
196        return path;
197    };
198    // Only bare `~` and `~/...` (or `~\...` on Windows) expand; a `~user` form
199    // would need a passwd lookup we do not do, so leave it untouched.
200    let mut chars = rest.chars();
201    let tail = match chars.next() {
202        None => "",
203        Some(separator) if std::path::is_separator(separator) => chars.as_str(),
204        Some(_) => return path,
205    };
206    let Some(home) = home else {
207        return path;
208    };
209    if tail.is_empty() {
210        home
211    } else {
212        home.join(tail)
213    }
214}
215
216pub fn submit(options: SubmitOptions) -> Result<()> {
217    let SubmitOptions {
218        branch,
219        submit_stack,
220        downstack,
221        dry_run,
222        push_mode,
223        title,
224        desc,
225        reviewers,
226        draft,
227        ready,
228        rebuild_overview,
229    } = options;
230
231    let branch = branch.map_or_else(git::current_branch, Ok)?;
232    // The title and description target this branch's review even in stack mode.
233    let target_branch = branch.clone();
234
235    let branches = if downstack {
236        // Bottom of the stack through the current branch: anything above is
237        // work in progress that stays local.
238        stack::path_from_root(&branch)?
239    } else if submit_stack {
240        // The stack containing the current branch: its own line, bottom
241        // through current and out to its descendants. Sibling stacks that
242        // merely share the trunk are left for their own submit.
243        stack::stack_line(&branch)?
244    } else {
245        vec![branch.clone()]
246    };
247
248    // The trunk is never part of a stack, so a stack-wide submit from it has
249    // nothing of its own to submit (its descendants are sibling stacks). Say so
250    // plainly rather than pushing an empty set or sweeping every stack.
251    if submit_stack || downstack {
252        let trunk = stack::trunk_branch(&git::local_branches()?);
253        if Some(&branch) == trunk.as_ref() {
254            if stack::children_of(&branch)?.is_empty() {
255                bail!("no stacked branches to submit");
256            }
257            bail!("you are on the trunk ({branch}); check out a stacked branch first");
258        }
259    }
260
261    let branch_parents = branch_parents(&branches)?;
262
263    // Push after stack validation but before any provider calls: creating a
264    // review requires the branch to exist remotely, and -u --force-with-lease
265    // covers both first pushes and safely updating rebased branches.
266    let push = settings::push_enabled(push_mode, settings::PUSH_ON_SUBMIT_KEY)?;
267    if push {
268        let remote = settings::remote()?;
269        if dry_run {
270            anstream::println!(
271                "would push {} to {remote}",
272                style::branch(&branches.join(" "))
273            );
274        } else {
275            git::push_set_upstream_force_with_lease(&remote, &branches)?;
276            anstream::println!("pushed {} to {remote}", style::branch(&branches.join(" ")));
277            // Carry the stack's parent map along so another clone can rebuild
278            // it with `git stk repair --from-remote`.
279            stack::publish_metadata(&remote);
280        }
281    }
282
283    let (provider, review_provider) = detect_review_provider()?;
284    let mut summary = SubmitSummary::default();
285
286    let mut created = Vec::new();
287    for (branch, parent) in &branch_parents {
288        // A new review opens under the given title directly, so it is never
289        // briefly published under the commit subject.
290        let branch_title = title.as_deref().filter(|_| *branch == target_branch);
291        let action = submit_branch(
292            review_provider.as_ref(),
293            branch,
294            parent,
295            dry_run,
296            draft,
297            branch_title,
298        )?;
299        if action == SubmitAction::Created {
300            created.push(branch.clone());
301        }
302        summary.record(action);
303    }
304
305    // Seed freshly created reviews from the repo's PR template before the
306    // managed sections go in, so our content joins it rather than replacing it.
307    // Without a user description the template is wrapped in the managed
308    // description block; the branch that gets `--desc` keeps the template
309    // freeform above a seam so the description reads as a distinct block below.
310    let desc_target = desc.as_ref().map(|_| target_branch.as_str());
311    crate::notes::seed_template_notes(
312        review_provider.as_ref(),
313        provider.kind,
314        &created,
315        desc_target,
316        dry_run,
317    )?;
318
319    // Flip drafts in scope to ready for review (the escape hatch for
320    // stk.submitDraft users).
321    if ready {
322        for branch in &branches {
323            let Some(review) = review_provider.review_for_branch(branch)? else {
324                continue;
325            };
326            if review.branch != *branch || !review.draft {
327                continue;
328            }
329            if dry_run {
330                anstream::println!("would mark {} ready", review.id);
331                continue;
332            }
333            let output = review_provider.mark_ready(&review)?;
334            anstream::println!("marked {} ready", review.id);
335            if !output.is_empty() {
336                println!("{output}");
337            }
338        }
339    }
340
341    // A renamed branch's fresh review now exists, so retire the review the old
342    // name still heads. Only handle this when the ledger prune below actually
343    // runs (stack-wide submit): the marker is the sole signal that identifies
344    // the stale row across every other overview, so closing and clearing it in
345    // a single-branch submit - which never prunes - would orphan those rows
346    // permanently. Left set, the marker waits for a later `submit --stack`.
347    let renamed: Vec<(String, String)> = if submit_stack || downstack {
348        branch_parents
349            .iter()
350            .filter_map(|(branch, _)| {
351                stack::renamed_from(branch)
352                    .ok()
353                    .flatten()
354                    .map(|old| (branch.clone(), old))
355            })
356            .collect()
357    } else {
358        Vec::new()
359    };
360    // Track which markers are safe to drop: those whose old review was
361    // actually retired (or had nothing to retire). A declined close keeps its
362    // marker so a later submit re-offers the reconciliation.
363    let mut reconciled: Vec<&str> = Vec::new();
364    for (branch, old) in &renamed {
365        if close_superseded_review(review_provider.as_ref(), old, dry_run)? {
366            reconciled.push(branch);
367        }
368    }
369
370    // After every review exists, set the title and description, link any issue
371    // the branch name references, then (in stack mode) write the stack overview
372    // into each body.
373    if let Some(title) = &title {
374        // Reviews created just now already carry the title; only one that
375        // existed before this submit needs the edit.
376        if !created.contains(&target_branch) {
377            apply_title(review_provider.as_ref(), &target_branch, title, dry_run)?;
378        }
379    }
380    if let Some(desc) = desc {
381        crate::notes::update_description_note(
382            review_provider.as_ref(),
383            &target_branch,
384            &desc,
385            dry_run,
386        )?;
387    }
388    crate::notes::update_closes_notes(review_provider.as_ref(), &branches, dry_run)?;
389    if submit_stack || downstack {
390        crate::notes::update_stack_notes(
391            review_provider.as_ref(),
392            &branch_parents,
393            dry_run,
394            rebuild_overview,
395        )?;
396    }
397    apply_reviewers(review_provider.as_ref(), &branches, &reviewers, dry_run)?;
398
399    // The ledger has now pruned the superseded entries, so drop the markers -
400    // but only for reviews that were retired, not ones the user kept.
401    if !dry_run {
402        for branch in &reconciled {
403            stack::clear_renamed_from(branch)?;
404        }
405    }
406
407    anstream::println!(
408        "{}",
409        style::success(&format!(
410            "submit complete: {} created, {} updated, {} skipped",
411            summary.created, summary.updated, summary.skipped
412        ))
413    );
414    Ok(())
415}
416
417/// Retire the open review still heading a renamed-away branch. The fresh
418/// review already exists, so closing here never leaves the work without one.
419/// Prompts (default yes; a non-interactive run proceeds) before closing.
420///
421/// Returns whether the supersession was reconciled: `true` when the old review
422/// was closed or there was nothing to close, `false` when the user declined -
423/// so the caller keeps the rename marker for a later submit to re-offer.
424fn close_superseded_review(
425    review_provider: &dyn ReviewProvider,
426    old: &str,
427    dry_run: bool,
428) -> Result<bool> {
429    let Some(review) = review_provider.review_for_branch(old)? else {
430        return Ok(true);
431    };
432    if review.branch != *old {
433        return Ok(true);
434    }
435
436    if dry_run {
437        anstream::println!("would close superseded review {} for {old}", review.id);
438        return Ok(true);
439    }
440    if !crate::prompt::confirm_default_yes(&format!(
441        "close the replaced review {} for {old} and delete its branch? [Y/n] ",
442        review.id
443    ))? {
444        anstream::println!("kept review {} for {old}", review.id);
445        return Ok(false);
446    }
447
448    review_provider.close_review(&review, true)?;
449    anstream::println!("closed superseded review {} for {old}", review.id);
450    Ok(true)
451}
452
453/// Retitle the branch's existing review. Mirrors the description step: a
454/// missing review, or one that heads a different branch, is passed over with a
455/// note rather than failing the submit.
456fn apply_title(
457    review_provider: &dyn ReviewProvider,
458    branch: &str,
459    title: &str,
460    dry_run: bool,
461) -> Result<()> {
462    let Some(review) = review_provider.review_for_branch(branch)? else {
463        if dry_run {
464            anstream::println!("would set the title on the review for {branch}");
465        } else {
466            anstream::println!("skipped title: no review found for {branch}");
467        }
468        return Ok(());
469    };
470    if review.branch != branch {
471        anstream::println!(
472            "skipped title: review {} belongs to {}",
473            review.id,
474            review.branch
475        );
476        return Ok(());
477    }
478    if dry_run {
479        anstream::println!("would set the title in {}", review.id);
480        return Ok(());
481    }
482
483    let output = review_provider.update_review_title(&review, title)?;
484    anstream::println!("set title in {}", review.id);
485    if !output.is_empty() {
486        println!("{output}");
487    }
488    Ok(())
489}
490
491/// Request reviews from `reviewers` on every submitted branch's review. A
492/// merged review is skipped - there is nothing left to review - and a branch
493/// whose review is missing (or heads a different branch) is passed over with a
494/// note, mirroring the description and closes steps. No-op with no reviewers.
495fn apply_reviewers(
496    review_provider: &dyn ReviewProvider,
497    branches: &[String],
498    reviewers: &[String],
499    dry_run: bool,
500) -> Result<()> {
501    if reviewers.is_empty() {
502        return Ok(());
503    }
504    let list = reviewers.join(", ");
505    for branch in branches {
506        let Some(review) = review_provider.review_for_branch(branch)? else {
507            // On a dry run the review was likely never created; for real the
508            // submit just failed to produce one, which deserves a mention.
509            if dry_run {
510                anstream::println!("would request reviews from {list} for {branch}");
511            } else {
512                anstream::println!("skipped reviewers: no review found for {branch}");
513            }
514            continue;
515        };
516        if review.branch != *branch || review.state == ReviewState::Merged {
517            continue;
518        }
519        if dry_run {
520            anstream::println!("would request reviews from {list} in {}", review.id);
521            continue;
522        }
523        let output = review_provider.request_reviewers(&review, reviewers)?;
524        anstream::println!("requested reviews from {list} in {}", review.id);
525        if !output.is_empty() {
526            println!("{output}");
527        }
528    }
529    Ok(())
530}
531
532fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
533    let mut branch_parents = Vec::new();
534    for branch in branches {
535        let Some(parent) = stack::parent_of(branch)? else {
536            bail!("{branch} has no stack parent; run `git stk adopt` or `git stk sync` first");
537        };
538        branch_parents.push((branch.to_owned(), parent));
539    }
540    Ok(branch_parents)
541}
542
543fn submit_branch(
544    review_provider: &dyn ReviewProvider,
545    branch: &str,
546    parent: &str,
547    dry_run: bool,
548    draft: bool,
549    title: Option<&str>,
550) -> Result<SubmitAction> {
551    if let Some(review) = review_provider.review_for_branch(branch)? {
552        if review.base == parent {
553            if dry_run {
554                anstream::println!(
555                    "would skip {} -> {} ({})",
556                    review.branch,
557                    review.base,
558                    review.id
559                );
560            } else {
561                anstream::println!(
562                    "{}",
563                    style::dim(&format!(
564                        "{} already targets {} ({})",
565                        review.branch, review.base, review.id
566                    ))
567                );
568            }
569            return Ok(SubmitAction::Skipped);
570        }
571
572        let output = if dry_run {
573            String::new()
574        } else {
575            review_provider.update_review_base(&review, parent)?
576        };
577        anstream::println!(
578            "{} {} -> {} {}",
579            if dry_run { "would update" } else { "updated" },
580            style::branch(&review.branch),
581            style::branch(parent),
582            style::dim(&format!("({})", review.id))
583        );
584        if !output.is_empty() {
585            println!("{output}");
586        }
587    } else {
588        let output = if dry_run {
589            String::new()
590        } else {
591            review_provider.create_review(branch, parent, draft, title)?
592        };
593        anstream::println!(
594            "{} {} -> {}{}",
595            if dry_run { "would create" } else { "created" },
596            style::branch(branch),
597            style::branch(parent),
598            title.map_or_else(String::new, |title| format!(" titled \"{title}\""))
599        );
600        if !output.is_empty() {
601            println!("{output}");
602        }
603        return Ok(SubmitAction::Created);
604    }
605
606    Ok(SubmitAction::Updated)
607}
608
609#[derive(Debug, Default)]
610struct SubmitSummary {
611    created: usize,
612    updated: usize,
613    skipped: usize,
614}
615
616impl SubmitSummary {
617    fn record(&mut self, action: SubmitAction) {
618        match action {
619            SubmitAction::Created => self.created += 1,
620            SubmitAction::Updated => self.updated += 1,
621            SubmitAction::Skipped => self.skipped += 1,
622        }
623    }
624}
625
626#[derive(Debug, Clone, Copy, Eq, PartialEq)]
627enum SubmitAction {
628    Created,
629    Updated,
630    Skipped,
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636
637    fn home() -> Option<PathBuf> {
638        Some(PathBuf::from("/home/dev"))
639    }
640
641    #[test]
642    fn expand_tilde_resolves_a_bare_tilde_and_subpaths() {
643        assert_eq!(
644            expand_tilde_with(PathBuf::from("~"), home()),
645            PathBuf::from("/home/dev")
646        );
647        assert_eq!(
648            expand_tilde_with(PathBuf::from("~/notes/pr.md"), home()),
649            PathBuf::from("/home/dev/notes/pr.md")
650        );
651    }
652
653    #[test]
654    fn expand_tilde_leaves_other_paths_untouched() {
655        // Absolute, relative, `~user`, and an embedded (non-leading) tilde all
656        // pass through unchanged.
657        for raw in ["/etc/pr.md", "notes/pr.md", "~alice/pr.md", "docs/~x.md"] {
658            assert_eq!(
659                expand_tilde_with(PathBuf::from(raw), home()),
660                PathBuf::from(raw)
661            );
662        }
663    }
664
665    #[test]
666    fn expand_tilde_passes_through_when_home_is_unset() {
667        assert_eq!(
668            expand_tilde_with(PathBuf::from("~/pr.md"), None),
669            PathBuf::from("~/pr.md")
670        );
671    }
672
673    fn reviewers(raw: &[&str]) -> Vec<String> {
674        normalize_reviewers(
675            &raw.iter()
676                .map(|entry| (*entry).to_owned())
677                .collect::<Vec<_>>(),
678        )
679    }
680
681    #[test]
682    fn normalize_reviewers_strips_at_and_trims() {
683        // A leading `@` is optional, so both spellings normalize alike.
684        assert_eq!(reviewers(&["@foo", "@bar"]), vec!["foo", "bar"]);
685        assert_eq!(reviewers(&["foo", "bar"]), vec!["foo", "bar"]);
686        assert_eq!(reviewers(&[" @foo ", "  bar"]), vec!["foo", "bar"]);
687    }
688
689    #[test]
690    fn normalize_reviewers_keeps_team_paths_but_drops_the_at() {
691        // Only the `@` prefix is stripped; the `org/team` slug stays intact.
692        assert_eq!(
693            reviewers(&["@my-org/backend", "acme/team"]),
694            vec!["my-org/backend", "acme/team"]
695        );
696    }
697
698    #[test]
699    fn normalize_reviewers_drops_blanks_and_dedupes_in_order() {
700        assert_eq!(
701            reviewers(&["foo", "", "  ", "@foo", "bar", "@bar"]),
702            vec!["foo", "bar"]
703        );
704    }
705
706    #[test]
707    fn normalize_reviewers_preserves_the_copilot_at_prefix() {
708        // gh needs the literal `@copilot`; a bare `copilot` canonicalizes to it,
709        // and both spellings collapse to one entry.
710        assert_eq!(reviewers(&["@copilot"]), vec!["@copilot"]);
711        assert_eq!(reviewers(&["copilot"]), vec!["@copilot"]);
712        assert_eq!(reviewers(&["@Copilot", "copilot"]), vec!["@copilot"]);
713    }
714
715    #[cfg(windows)]
716    #[test]
717    fn expand_tilde_accepts_a_backslash_on_windows() {
718        assert_eq!(
719            expand_tilde_with(PathBuf::from(r"~\notes\pr.md"), home()),
720            PathBuf::from("/home/dev").join(r"notes\pr.md")
721        );
722    }
723}