pub mod checkout;
pub mod complete;
pub mod completions;
pub mod config_cmd;
pub mod drop;
pub mod init;
pub mod list;
pub mod new;
pub mod path;
pub mod pr;
pub mod pr_open;
pub mod prune;
pub mod remove;
pub mod root;
pub mod shell_init;
pub mod staleness;
pub mod status_cmd;
pub mod switch;
pub mod sync;
use std::path::{Path, PathBuf};
use crate::config::{self, Config, SubmoduleInit};
use crate::cx::{Cx, Env};
use crate::error::{Error, Result};
use crate::git::cli::GitCli;
use crate::git::discover::Repo;
use crate::model::Worktree;
use crate::query::{self, Resolved};
use crate::template::{self, TemplateVars};
use crate::worktree_service::build_worktrees;
pub(crate) struct Session {
pub(crate) repo: Repo,
pub(crate) primary_root: PathBuf,
pub(crate) config: Config,
}
pub(crate) fn open_session(cx: &Cx, git: &dyn GitCli) -> Result<Session> {
let repo = Repo::discover(&cx.cwd)?;
let dir = repo.current_workdir().unwrap_or_else(|| repo.git_dir());
let common = git.run(
&dir,
&["rev-parse", "--path-format=absolute", "--git-common-dir"],
)?;
let common = PathBuf::from(common.trim());
let primary_root = if repo.is_bare() {
common
} else {
common.parent().map(Path::to_path_buf).unwrap_or(common)
};
let config = config::load(Some(&primary_root), &cx.env)?;
Ok(Session {
repo,
primary_root,
config,
})
}
pub(crate) enum Resolution {
Found(usize),
Ambiguous,
NotFound,
}
pub(crate) fn resolve_query(cx: &mut Cx, worktrees: &[Worktree], query: &str) -> Resolution {
resolve_query_with(cx, worktrees, query, |cx, q| {
crate::tui::run_tui(cx, Some(q))
})
}
fn resolve_query_with(
cx: &mut Cx,
worktrees: &[Worktree],
query: &str,
picker: impl FnOnce(&mut Cx, &str) -> Result<Option<PathBuf>>,
) -> Resolution {
match query::resolve(worktrees, query) {
Resolved::One(index) => Resolution::Found(index),
Resolved::Ambiguous(indices) => {
if cx.err.is_tty() {
match picker(cx, query) {
Ok(Some(path)) => worktrees
.iter()
.position(|w| same_path(&w.path, &path))
.map_or(Resolution::Ambiguous, Resolution::Found),
Ok(None) => Resolution::Ambiguous,
Err(_) => list_candidates(cx, worktrees, query, &indices),
}
} else {
list_candidates(cx, worktrees, query, &indices)
}
}
Resolved::NotFound => Resolution::NotFound,
}
}
fn list_candidates(
cx: &mut Cx,
worktrees: &[Worktree],
query: &str,
indices: &[usize],
) -> Resolution {
let _ = cx
.err
.line(&format!("query {query:?} is ambiguous; candidates:"));
for &index in indices {
let _ = cx
.err
.line(&format!(" {}", candidate_label(&worktrees[index])));
}
Resolution::Ambiguous
}
pub(crate) fn candidate_label(worktree: &Worktree) -> String {
match &worktree.branch {
Some(branch) => branch.clone(),
None => worktree.path.display().to_string(),
}
}
pub(crate) fn same_path(a: &Path, b: &Path) -> bool {
let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
canon(a) == canon(b)
}
pub(crate) fn confirm(cx: &mut Cx, prompt: &str) -> Result<bool> {
cx.err.text(prompt)?;
cx.err.flush()?;
let line = cx.input.read_line()?;
let answer = line.trim().to_ascii_lowercase();
Ok(answer == "y" || answer == "yes")
}
pub(crate) fn confirm_default_yes(cx: &mut Cx, prompt: &str) -> Result<bool> {
cx.err.text(prompt)?;
cx.err.flush()?;
let line = cx.input.read_line()?;
let answer = line.trim().to_ascii_lowercase();
Ok(answer != "n" && answer != "no")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Choice {
Update,
Proceed,
Cancel,
}
pub(crate) fn choose(cx: &mut Cx, prompt: &str) -> Result<Choice> {
cx.err.text(prompt)?;
cx.err.flush()?;
let line = cx.input.read_line()?;
Ok(match line.trim().to_ascii_lowercase().as_str() {
"u" | "update" => Choice::Update,
"p" | "proceed" => Choice::Proceed,
_ => Choice::Cancel,
})
}
pub(crate) fn maybe_init_submodules(
cx: &mut Cx,
git: &dyn GitCli,
dir: &Path,
policy: SubmoduleInit,
flag_override: Option<bool>,
) -> Result<()> {
let enabled = flag_override.unwrap_or(matches!(policy, SubmoduleInit::Always));
if !enabled {
return Ok(());
}
let pending = crate::git::submodule::uninitialized(git, dir)?;
if pending.is_empty() {
return Ok(());
}
init_submodules(cx, git, dir, pending.len());
Ok(())
}
pub(crate) fn maybe_init_submodules_interactive(
cx: &mut Cx,
git: &dyn GitCli,
dir: &Path,
policy: SubmoduleInit,
flag_override: Option<bool>,
prompt: bool,
) -> Result<()> {
if flag_override.is_some() || !matches!(policy, SubmoduleInit::Prompt) {
return maybe_init_submodules(cx, git, dir, policy, flag_override);
}
if !(prompt && cx.err.is_tty()) {
return Ok(());
}
let pending = crate::git::submodule::uninitialized(git, dir)?;
if pending.is_empty() {
return Ok(());
}
let ask = format!(
"repository has {} uninitialized submodule(s); initialize recursively (`git submodule update --init --recursive`)? [Y/n] ",
pending.len()
);
if confirm_default_yes(cx, &ask)? {
init_submodules(cx, git, dir, pending.len());
}
Ok(())
}
fn init_submodules(cx: &mut Cx, git: &dyn GitCli, dir: &Path, count: usize) {
let _ = cx.err.line(&format!("initializing {count} submodule(s)โฆ"));
if let Err(e) = crate::git::submodule::update_init(git, dir) {
let _ = cx
.err
.line(&format!("warning: failed to initialize submodules: {e}"));
}
}
pub(crate) fn git_dir_of(root: &Path, is_bare: bool) -> PathBuf {
if is_bare {
root.to_path_buf()
} else {
root.join(".git")
}
}
pub(crate) fn render_target(
config: &Config,
root: &Path,
branch: &str,
slug: &str,
env: &Env,
) -> Result<PathBuf> {
let vars = TemplateVars {
repo_parent: root
.parent()
.map_or_else(|| root.to_path_buf(), Path::to_path_buf),
repo: root
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
repo_root: root.to_path_buf(),
branch: branch.to_string(),
branch_slug: slug.to_string(),
home: env
.get("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("~")),
};
template::render(&config.path_template, &vars)
}
pub(crate) fn resolve_target(
config: &Config,
root: &Path,
branch: &str,
slug: &str,
short_hash: &str,
env: &Env,
is_bare: bool,
) -> Result<PathBuf> {
let target = render_target(config, root, branch, slug, env)?;
template::ensure_outside_git(&target, &git_dir_of(root, is_bare))?;
if !target.exists() {
return Ok(target);
}
let alt = render_target(config, root, branch, &format!("{slug}-{short_hash}"), env)?;
if alt.exists() {
return Err(Error::operation(format!(
"target path already exists: {}",
target.display()
)));
}
Ok(alt)
}
pub(crate) fn run_best_effort(git: &dyn GitCli, root: &Path, args: &[&str], step: &str) {
match git.run_raw(root, args) {
Ok(out) if out.success => {}
Ok(out) => {
tracing::debug!(step, stderr = %out.stderr.trim(), "best-effort cleanup step failed");
}
Err(error) => {
tracing::debug!(step, %error, "best-effort cleanup step could not run");
}
}
}
pub(crate) fn rollback_worktree(
git: &dyn GitCli,
root: &Path,
target: &Path,
branch: &str,
delete_branch: bool,
clear_metadata: bool,
) {
let target_str = target.to_string_lossy();
run_best_effort(
git,
root,
&["worktree", "remove", "--force", &target_str],
"rollback: worktree remove",
);
run_best_effort(
git,
root,
&["worktree", "prune"],
"rollback: worktree prune",
);
if delete_branch {
run_best_effort(
git,
root,
&["branch", "-D", branch],
"rollback: branch delete",
);
}
if clear_metadata {
let _ = crate::config::wtconfig::clear_meta(git, root, branch);
}
}
pub(crate) fn build_target_row(cx: &Cx, target: &Path) -> Result<Worktree> {
let git = cx.git.clone();
let repo = Repo::discover(&cx.cwd)?;
let worktrees = build_worktrees(&repo, git.as_ref())?;
worktrees
.into_iter()
.find(|w| same_path(&w.path, target))
.ok_or_else(|| Error::operation("created worktree not found"))
}
pub(crate) fn log_copy_outcome(cx: &mut Cx, outcome: &crate::copy::CopyOutcome) {
if cx.verbose == 0 {
return;
}
for path in &outcome.copied {
let _ = cx.err.line(&format!("copied {}", path.display()));
}
for path in &outcome.skipped_existing {
let _ = cx
.err
.line(&format!("skipped (target exists) {}", path.display()));
}
}
pub(crate) fn emit_worktree(
cx: &mut Cx,
target: &Path,
json: bool,
no_switch: bool,
note: &str,
) -> Result<u8> {
if json {
let row = build_target_row(cx, target)?;
cx.out.line(&row.to_json_line()?)?;
} else if no_switch {
cx.err.line(&format!("{note} {}", target.display()))?;
} else {
cx.out.line(&target.to_string_lossy())?;
}
Ok(0)
}
#[cfg(test)]
mod tests {
use super::{Resolution, resolve_query_with};
use crate::cx::Stream;
use crate::model::Worktree;
use crate::testutil::{SharedBuf, test_cx};
use std::path::PathBuf;
fn wt(branch: &str) -> Worktree {
let slug = branch.replace('/', "-");
let mut w = Worktree::new(PathBuf::from(format!("/r/{slug}")));
w.branch = Some(branch.to_string());
w.slug = Some(slug);
w
}
fn worktrees() -> Vec<Worktree> {
vec![wt("main"), wt("feature/login"), wt("feature/logout")]
}
fn cx_with_err_tty(is_tty: bool) -> (crate::testutil::TestCx, SharedBuf) {
let mut t = test_cx(&[], "/work");
let err = SharedBuf::new();
t.cx.err = Stream::new(Box::new(err.clone()), is_tty);
(t, err)
}
#[test]
fn unique_match_is_found() {
let (mut t, _err) = cx_with_err_tty(false);
let r = resolve_query_with(&mut t.cx, &worktrees(), "main", |_, _| {
panic!("picker must not run for a unique match")
});
assert!(matches!(r, Resolution::Found(0)));
}
#[test]
fn no_match_is_not_found() {
let (mut t, _err) = cx_with_err_tty(true);
let r = resolve_query_with(&mut t.cx, &worktrees(), "zzz", |_, _| {
panic!("picker must not run when nothing matches")
});
assert!(matches!(r, Resolution::NotFound));
}
#[test]
fn non_tty_ambiguous_lists_candidates() {
let (mut t, err) = cx_with_err_tty(false);
let r = resolve_query_with(&mut t.cx, &worktrees(), "feature/log", |_, _| {
panic!("picker must not run when stderr is not a TTY")
});
assert!(matches!(r, Resolution::Ambiguous));
let out = err.contents();
assert!(out.contains("ambiguous"));
assert!(out.contains("feature/login"));
assert!(out.contains("feature/logout"));
}
#[test]
fn tty_picker_selection_maps_to_index() {
let (mut t, err) = cx_with_err_tty(true);
let wts = worktrees();
let chosen = wts[2].path.clone();
let r = resolve_query_with(&mut t.cx, &wts, "feature/log", move |_, q| {
assert_eq!(q, "feature/log");
Ok(Some(chosen))
});
assert!(matches!(r, Resolution::Found(2)));
assert!(err.contents().is_empty());
}
#[test]
fn tty_picker_cancel_is_ambiguous_without_output() {
let (mut t, err) = cx_with_err_tty(true);
let r = resolve_query_with(&mut t.cx, &worktrees(), "feature/log", |_, _| Ok(None));
assert!(matches!(r, Resolution::Ambiguous));
assert!(err.contents().is_empty());
}
#[test]
fn tty_picker_error_falls_back_to_listing() {
let (mut t, err) = cx_with_err_tty(true);
let r = resolve_query_with(&mut t.cx, &worktrees(), "feature/log", |_, _| {
Err(crate::error::Error::operation("boom"))
});
assert!(matches!(r, Resolution::Ambiguous));
assert!(err.contents().contains("ambiguous"));
assert!(err.contents().contains("feature/login"));
}
#[test]
fn tty_picker_unknown_path_is_ambiguous() {
let (mut t, err) = cx_with_err_tty(true);
let r = resolve_query_with(&mut t.cx, &worktrees(), "feature/log", |_, _| {
Ok(Some(PathBuf::from("/somewhere/else")))
});
assert!(matches!(r, Resolution::Ambiguous));
assert!(err.contents().is_empty());
}
#[test]
fn confirm_default_yes_treats_empty_as_yes() {
use crate::commands::confirm_default_yes;
use crate::testutil::CannedInput;
let cases = [
("", true),
("y", true),
("yes", true),
("anything", true),
("n", false),
("N", false),
("no", false),
];
for (answer, expected) in cases {
let mut t = test_cx(&[], "/work");
t.cx.input = Box::new(CannedInput::new(&[answer]));
assert_eq!(
confirm_default_yes(&mut t.cx, "? ").unwrap(),
expected,
"answer {answer:?}"
);
}
}
#[test]
fn choose_maps_answers_and_defaults_to_cancel() {
use crate::commands::{Choice, choose};
use crate::testutil::CannedInput;
let cases = [
("update", Choice::Update),
("u", Choice::Update),
("proceed", Choice::Proceed),
("p", Choice::Proceed),
("", Choice::Cancel),
("nonsense", Choice::Cancel),
];
for (answer, expected) in cases {
let mut t = test_cx(&[], "/work");
t.cx.input = Box::new(CannedInput::new(&[answer]));
assert_eq!(choose(&mut t.cx, "? ").unwrap(), expected);
}
}
mod submodules {
use super::cx_with_err_tty;
use crate::commands::{maybe_init_submodules, maybe_init_submodules_interactive};
use crate::config::SubmoduleInit;
use crate::git::cli::{GitCli, GitOutput, RealGit};
use crate::git::submodule::uninitialized;
use crate::testutil::{CannedInput, TestRepo};
use std::path::Path;
fn repo_with_uninitialized_submodule() -> TestRepo {
let repo = TestRepo::init();
repo.add_submodule("libs/sub");
repo.deinit_submodule("libs/sub");
repo
}
fn is_initialized(repo: &TestRepo) -> bool {
repo.root().join("libs/sub/sub.txt").exists()
}
#[test]
fn disabled_policy_is_a_noop() {
let repo = repo_with_uninitialized_submodule();
let (mut t, err) = cx_with_err_tty(true);
maybe_init_submodules(&mut t.cx, &RealGit, repo.root(), SubmoduleInit::Never, None)
.unwrap();
assert!(!is_initialized(&repo));
assert!(err.contents().is_empty());
}
#[test]
fn always_policy_initializes() {
let repo = repo_with_uninitialized_submodule();
let (mut t, err) = cx_with_err_tty(true);
maybe_init_submodules(
&mut t.cx,
&RealGit,
repo.root(),
SubmoduleInit::Always,
None,
)
.unwrap();
assert!(is_initialized(&repo));
assert!(uninitialized(&RealGit, repo.root()).unwrap().is_empty());
assert!(err.contents().contains("initializing 1 submodule"));
}
#[test]
fn flag_override_forces_init_over_never() {
let repo = repo_with_uninitialized_submodule();
let (mut t, _err) = cx_with_err_tty(true);
maybe_init_submodules(
&mut t.cx,
&RealGit,
repo.root(),
SubmoduleInit::Never,
Some(true),
)
.unwrap();
assert!(is_initialized(&repo));
}
#[test]
fn flag_override_forces_skip_over_always() {
let repo = repo_with_uninitialized_submodule();
let (mut t, _err) = cx_with_err_tty(true);
maybe_init_submodules(
&mut t.cx,
&RealGit,
repo.root(),
SubmoduleInit::Always,
Some(false),
)
.unwrap();
assert!(!is_initialized(&repo));
}
#[test]
fn enabled_with_no_submodules_is_a_noop() {
let repo = TestRepo::init();
let (mut t, err) = cx_with_err_tty(true);
maybe_init_submodules(
&mut t.cx,
&RealGit,
repo.root(),
SubmoduleInit::Always,
None,
)
.unwrap();
assert!(err.contents().is_empty());
}
struct StatusOkUpdateFails;
impl GitCli for StatusOkUpdateFails {
fn run_raw(&self, _repo: &Path, _args: &[&str]) -> crate::error::Result<GitOutput> {
Ok(GitOutput {
success: true,
stdout: "-deadbeef libs/sub\n".into(),
stderr: String::new(),
})
}
fn run(&self, _repo: &Path, _args: &[&str]) -> crate::error::Result<String> {
Err(crate::error::Error::operation("boom"))
}
}
#[test]
fn prompt_default_yes_empty_initializes() {
let repo = repo_with_uninitialized_submodule();
let (mut t, err) = cx_with_err_tty(true);
t.cx.input = Box::new(CannedInput::new(&[""]));
maybe_init_submodules_interactive(
&mut t.cx,
&RealGit,
repo.root(),
SubmoduleInit::Prompt,
None,
true,
)
.unwrap();
assert!(is_initialized(&repo));
assert!(err.contents().contains("uninitialized submodule"));
}
#[test]
fn prompt_no_leaves_uninitialized() {
let repo = repo_with_uninitialized_submodule();
let (mut t, err) = cx_with_err_tty(true);
t.cx.input = Box::new(CannedInput::new(&["n"]));
maybe_init_submodules_interactive(
&mut t.cx,
&RealGit,
repo.root(),
SubmoduleInit::Prompt,
None,
true,
)
.unwrap();
assert!(!is_initialized(&repo));
assert!(err.contents().contains("uninitialized submodule"));
assert!(!err.contents().contains("initializing"));
}
#[test]
fn prompt_with_no_submodules_does_not_ask() {
let repo = TestRepo::init();
let (mut t, err) = cx_with_err_tty(true);
maybe_init_submodules_interactive(
&mut t.cx,
&RealGit,
repo.root(),
SubmoduleInit::Prompt,
None,
true,
)
.unwrap();
assert!(err.contents().is_empty());
}
#[test]
fn prompt_non_tty_is_a_silent_skip() {
let repo = repo_with_uninitialized_submodule();
let (mut t, err) = cx_with_err_tty(false);
maybe_init_submodules_interactive(
&mut t.cx,
&RealGit,
repo.root(),
SubmoduleInit::Prompt,
None,
true,
)
.unwrap();
assert!(!is_initialized(&repo));
assert!(err.contents().is_empty());
}
#[test]
fn prompt_false_gate_skips_even_at_a_tty() {
let repo = repo_with_uninitialized_submodule();
let (mut t, err) = cx_with_err_tty(true);
maybe_init_submodules_interactive(
&mut t.cx,
&RealGit,
repo.root(),
SubmoduleInit::Prompt,
None,
false,
)
.unwrap();
assert!(!is_initialized(&repo));
assert!(err.contents().is_empty());
}
#[test]
fn flag_skip_overrides_prompt_without_asking() {
let repo = repo_with_uninitialized_submodule();
let (mut t, err) = cx_with_err_tty(true);
maybe_init_submodules_interactive(
&mut t.cx,
&RealGit,
repo.root(),
SubmoduleInit::Prompt,
Some(false),
true,
)
.unwrap();
assert!(!is_initialized(&repo));
assert!(err.contents().is_empty());
}
#[test]
fn flag_init_overrides_prompt_without_asking() {
let repo = repo_with_uninitialized_submodule();
let (mut t, err) = cx_with_err_tty(true);
maybe_init_submodules_interactive(
&mut t.cx,
&RealGit,
repo.root(),
SubmoduleInit::Prompt,
Some(true),
true,
)
.unwrap();
assert!(is_initialized(&repo));
assert!(err.contents().contains("initializing"));
assert!(!err.contents().contains("uninitialized submodule"));
}
#[test]
fn never_policy_does_not_ask_at_a_tty() {
let repo = repo_with_uninitialized_submodule();
let (mut t, err) = cx_with_err_tty(true);
maybe_init_submodules_interactive(
&mut t.cx,
&RealGit,
repo.root(),
SubmoduleInit::Never,
None,
true,
)
.unwrap();
assert!(!is_initialized(&repo));
assert!(err.contents().is_empty());
}
#[test]
fn update_failure_is_non_fatal_and_warns() {
let (mut t, err) = cx_with_err_tty(true);
maybe_init_submodules(
&mut t.cx,
&StatusOkUpdateFails,
Path::new("/work"),
SubmoduleInit::Always,
None,
)
.unwrap();
let out = err.contents();
assert!(out.contains("initializing 1 submodule"));
assert!(out.contains("warning: failed to initialize submodules"));
assert!(out.contains("boom"));
}
}
}