git_prole/git/worktree/
mod.rs

1use std::borrow::Cow;
2use std::ffi::OsStr;
3use std::fmt::Debug;
4use std::process::Command;
5
6use camino::Utf8Path;
7use camino::Utf8PathBuf;
8use command_error::CommandExt;
9use command_error::OutputContext;
10use miette::miette;
11use miette::Context;
12use rustc_hash::FxHashMap;
13use tap::Tap;
14use tracing::instrument;
15use utf8_command::Utf8Output;
16
17use crate::config::BranchReplacement;
18use crate::final_component;
19use crate::AppGit;
20
21use super::BranchRef;
22use super::GitLike;
23use super::LocalBranchRef;
24
25mod resolve_unique_names;
26
27mod parse;
28
29pub use parse::Worktree;
30pub use parse::WorktreeHead;
31pub use parse::Worktrees;
32pub use resolve_unique_names::RenamedWorktree;
33pub use resolve_unique_names::ResolveUniqueNameOpts;
34
35/// Git methods for dealing with worktrees.
36#[repr(transparent)]
37pub struct GitWorktree<'a, G>(&'a G);
38
39impl<G> Debug for GitWorktree<'_, G>
40where
41    G: GitLike,
42{
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.debug_tuple("GitWorktree")
45            .field(&self.0.get_current_dir().as_ref())
46            .finish()
47    }
48}
49
50impl<'a, G> GitWorktree<'a, G>
51where
52    G: GitLike,
53{
54    pub fn new(git: &'a G) -> Self {
55        Self(git)
56    }
57
58    /// Get the 'main' worktree. There can only be one main worktree, and it contains the
59    /// common `.git` directory.
60    ///
61    /// See: <https://stackoverflow.com/a/68754000>
62    #[instrument(level = "trace")]
63    pub fn main(&self) -> miette::Result<Worktree> {
64        // Kinda wasteful; we parse all the worktrees and then throw them away.
65        Ok(self.list()?.into_main())
66    }
67
68    /// Get the worktree container directory.
69    ///
70    /// This is the main worktree's parent, and is usually where all the other worktrees are
71    /// cloned as well.
72    #[instrument(level = "trace")]
73    pub fn container(&self) -> miette::Result<Utf8PathBuf> {
74        // TODO: Write `.git-prole` to indicate worktree container root?
75        let mut path = self.main()?.path;
76        if !path.pop() {
77            Err(miette!("Main worktree path has no parent: {path}"))
78        } else {
79            Ok(path)
80        }
81    }
82
83    /// List Git worktrees.
84    #[instrument(level = "trace")]
85    pub fn list(&self) -> miette::Result<Worktrees> {
86        Ok(self
87            .0
88            .command()
89            .args(["worktree", "list", "--porcelain", "-z"])
90            .output_checked_as(|context: OutputContext<Utf8Output>| {
91                if !context.status().success() {
92                    Err(context.error())
93                } else {
94                    let output = &context.output().stdout;
95                    match Worktrees::parse(self.0.as_git(), output) {
96                        Ok(worktrees) => Ok(worktrees),
97                        Err(err) => {
98                            let err = miette!("{err}");
99                            Err(context.error_msg(err))
100                        }
101                    }
102                }
103            })?)
104    }
105
106    /// Check if we're inside a working tree.
107    ///
108    /// This will return false for a bare worktree like a `.git` directory!
109    #[instrument(level = "trace")]
110    pub fn is_inside(&self) -> miette::Result<bool> {
111        Ok(self
112            .0
113            .as_git()
114            .rev_parse_command()
115            .arg("--is-inside-work-tree")
116            .output_checked_as(|context: OutputContext<Utf8Output>| {
117                if !context.status().success() {
118                    Err(context.error())
119                } else {
120                    let stdout = context.output().stdout.trim();
121                    match stdout {
122                        "true" => Ok(true),
123                        "false" => Ok(false),
124                        _ => Err(context.error_msg("Expected 'true' or 'false'")),
125                    }
126                }
127            })?)
128    }
129
130    /// Get the root of this worktree. Fails if not in a worktree.
131    #[instrument(level = "trace")]
132    pub fn root(&self) -> miette::Result<Utf8PathBuf> {
133        Ok(self
134            .0
135            .as_git()
136            .rev_parse_command()
137            .arg("--show-toplevel")
138            .output_checked_utf8()
139            .wrap_err("Failed to get worktree root")?
140            .stdout
141            .trim()
142            .into())
143    }
144
145    #[instrument(level = "trace")]
146    pub fn add(&self, path: &Utf8Path, options: &AddWorktreeOpts<'_>) -> miette::Result<()> {
147        self.add_command(path, options).status_checked()?;
148        Ok(())
149    }
150
151    #[instrument(level = "trace")]
152    pub fn add_command(&self, path: &Utf8Path, options: &AddWorktreeOpts<'_>) -> Command {
153        let mut command = self.0.command();
154        command.args(["worktree", "add"]);
155
156        if options.detach {
157            command.arg("--detach");
158        }
159
160        if let Some(branch) = options.create_branch {
161            command.arg(if options.force_branch { "-B" } else { "-b" });
162            command.arg(branch.branch_name());
163        }
164
165        if !options.checkout {
166            command.arg("--no-checkout");
167        }
168
169        if options.guess_remote {
170            command.arg("--guess-remote");
171        }
172
173        if options.track {
174            command.arg("--track");
175        }
176
177        command.arg(path.as_str());
178
179        if let Some(start_point) = options.start_point {
180            command.arg(start_point);
181        }
182
183        command
184    }
185
186    #[instrument(level = "trace")]
187    pub fn rename(&self, from: &Utf8Path, to: &Utf8Path) -> miette::Result<()> {
188        self.0
189            .command()
190            .current_dir(from)
191            .args(["worktree", "move", from.as_str(), to.as_str()])
192            .status_checked()?;
193        Ok(())
194    }
195
196    #[instrument(level = "trace")]
197    pub fn repair(
198        &self,
199        paths: impl IntoIterator<Item = impl AsRef<OsStr>> + Debug,
200    ) -> miette::Result<()> {
201        self.0
202            .command()
203            .args(["worktree", "repair"])
204            .args(paths)
205            .output_checked_utf8()?;
206        Ok(())
207    }
208}
209
210/// Options for `git worktree add`.
211#[derive(Clone, Copy, Debug)]
212pub struct AddWorktreeOpts<'a> {
213    /// If true, use `-B` instead of `-b` for `create_branch`.
214    /// Default false.
215    pub force_branch: bool,
216    /// Create a new branch.
217    pub create_branch: Option<&'a LocalBranchRef>,
218    /// If false, use `--no-checkout`.
219    /// Default true.
220    pub checkout: bool,
221    /// If true, use `--guess-remote`.
222    /// Default false.
223    pub guess_remote: bool,
224    /// If true, use `--track`.
225    /// Default false.
226    pub track: bool,
227    /// The start point for the new worktree.
228    pub start_point: Option<&'a str>,
229    /// If true, use `--detach`.
230    /// Default false.
231    pub detach: bool,
232}
233
234impl<'a> Default for AddWorktreeOpts<'a> {
235    fn default() -> Self {
236        Self {
237            force_branch: false,
238            create_branch: None,
239            checkout: true,
240            guess_remote: false,
241            track: false,
242            start_point: None,
243            detach: false,
244        }
245    }
246}
247
248impl<'a, C> GitWorktree<'a, AppGit<'a, C>>
249where
250    C: AsRef<Utf8Path>,
251{
252    /// The directory name, nested under the worktree parent directory, where the given
253    /// branch's worktree will be placed.
254    ///
255    /// E.g. to convert a repo `~/puppy` with default branch `main`, this will return `main`,
256    /// to indicate a worktree to be placed in `~/puppy/main`.
257    pub fn dirname_for<'b>(&self, branch: &'b str) -> Cow<'b, str> {
258        let branch_replacements = self.0.config.file.add.branch_replacements();
259        if branch_replacements.is_empty() {
260            Cow::Borrowed(final_component(branch))
261        } else {
262            let mut dirname = branch.to_owned();
263            for BranchReplacement {
264                find,
265                replace,
266                count,
267            } in branch_replacements
268            {
269                dirname = match count {
270                    Some(count) => find.replacen(&dirname, *count, replace),
271                    None => find.replace_all(&dirname, replace),
272                }
273                .into_owned();
274            }
275
276            if dirname.contains(std::path::MAIN_SEPARATOR_STR) {
277                let final_component = final_component(&dirname);
278                tracing::warn!(
279                    %branch,
280                    after_replacements=%dirname,
281                    using=%final_component,
282                    "Applying `add.branch_replacements` substitutions resulted in a directory name which includes a `{}`",
283                    std::path::MAIN_SEPARATOR_STR,
284                );
285                final_component.to_owned().into()
286            } else {
287                dirname.into()
288            }
289        }
290    }
291
292    /// Get the full path for a new worktree with the given branch name.
293    ///
294    /// This appends the [`Self::dirname_for`] to the [`Self::container`].
295    #[instrument(level = "trace")]
296    pub fn path_for(&self, branch: &str) -> miette::Result<Utf8PathBuf> {
297        Ok(self
298            .container()?
299            .tap_mut(|p| p.push(&*self.dirname_for(branch))))
300    }
301
302    /// Resolves a set of worktrees into a map from worktree paths to unique names.
303    #[instrument(level = "trace")]
304    pub fn resolve_unique_names(
305        &self,
306        opts: ResolveUniqueNameOpts<'_>,
307    ) -> miette::Result<FxHashMap<Utf8PathBuf, RenamedWorktree>> {
308        resolve_unique_names::resolve_unique_worktree_names(self.0, opts)
309    }
310
311    /// Get the worktree for the preferred branch, if any.
312    #[instrument(level = "trace")]
313    pub fn preferred_branch(
314        &self,
315        preferred_branch: Option<&BranchRef>,
316        worktrees: Option<&Worktrees>,
317    ) -> miette::Result<Option<Worktree>> {
318        let worktrees = match worktrees {
319            Some(worktrees) => worktrees,
320            None => &self.list()?,
321        };
322        let preferred_branch = match preferred_branch {
323            Some(preferred_branch) => preferred_branch,
324            None => &match self.0.branch().preferred()? {
325                Some(preferred_branch) => preferred_branch,
326                None => {
327                    return Ok(None);
328                }
329            },
330        };
331
332        // TODO: Check for branch with the default as an upstream as well?
333        Ok(worktrees.for_branch(&preferred_branch.as_local()).cloned())
334    }
335
336    /// Get the path to _some_ worktree.
337    ///
338    /// This prefers, in order:
339    /// 1. The current worktree.
340    /// 2. The worktree for the default branch.
341    /// 3. Any non-bare worktree.
342    /// 4. A bare worktree.
343    #[instrument(level = "trace")]
344    pub fn find_some(&self) -> miette::Result<Utf8PathBuf> {
345        if self.is_inside()? {
346            tracing::debug!("Inside worktree");
347            // Test: `add_by_path`
348            return self.root();
349        }
350        let worktrees = self.list()?;
351
352        if let Some(worktree) = self.preferred_branch(None, Some(&worktrees))? {
353            tracing::debug!(%worktree, "Found worktree for preferred branch");
354            // Test: `add_from_container`
355            return Ok(worktree.path);
356        }
357
358        tracing::debug!("No worktree for preferred branch");
359
360        if worktrees.main().head.is_bare() && worktrees.len() > 1 {
361            // Find a non-bare worktree.
362            //
363            // Test: `add_from_container_no_default_branch`
364            let worktree = worktrees
365                .into_iter()
366                .find(|(_path, worktree)| !worktree.head.is_bare())
367                .expect("Only one worktree can be bare")
368                .0;
369
370            tracing::debug!(%worktree, "Found non-bare worktree");
371            return Ok(worktree);
372        }
373
374        // Otherwise, get the main worktree.
375        // Either the main worktree is bare and there's no other worktrees, or the main
376        // worktree is not bare.
377        //
378        // Note: If the main worktree isn't bare, there's no way to run Git commands
379        // without being in a worktree. IDK I guess you can probably do something silly
380        // with separating the Git directory and the worktree but like, why.
381        //
382        // Tests:
383        // - `add_from_bare_no_worktrees`
384        tracing::debug!("Non-bare main worktree or no non-bare worktrees");
385        Ok(worktrees.main_path().to_owned())
386    }
387}