gix 0.89.0

Interact with git repositories just like git would
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
use std::path::Path;

use gix_error::{ClassificationMarker, ErrorExt, ResultExt, bail, message};

use crate::{Result, bstr::BString};
use gix_path::realpath::MAX_SYMLINKS;
use gix_utils::progress::{Count, NestedProgress, Progress};

pub use gix_worktree::remove::Options;

/// The amount of validation to bypass when removing a linked worktree.
#[derive(Default, Debug, Copy, Clone, Eq, PartialEq)]
pub enum Force {
    /// Refuse to remove a dirty worktree or one containing initialized submodules.
    #[default]
    Never,
    /// Discard changes and initialized submodules, but retain locked worktrees.
    DiscardChanges,
    /// Also override a worktree lock.
    OverrideLock,
}

impl Force {
    fn discards_changes(self) -> bool {
        !matches!(self, Force::Never)
    }
}

/// A resolved linked worktree ready for inspection or removal.
pub struct Target<'repo> {
    /// The selected worktree's registration, providing access to its private Git directory, lock, and repository.
    proxy: crate::worktree::Proxy<'repo>,
    /// The checkout path cached from the registration's `gitdir` file during target resolution.
    /// The checkout itself may be missing or inaccessible.
    base: std::path::PathBuf,
    /// Options controlling traversal, deletion parallelism, and retries for both removal roots.
    options: Options,
}

impl Target<'_> {
    /// Return the absolute checkout directory, whether or not it is currently accessible.
    pub fn base(&self) -> &Path {
        &self.base
    }

    /// Open this worktree's repository, even if its checkout is currently inaccessible.
    pub fn repository(&self) -> Result<crate::Repository> {
        self.proxy.clone().into_repo_with_possibly_inaccessible_worktree()
    }

    /// Set the options for recursively deleting the checkout and its private Git directory.
    ///
    /// Defaults to [`Options::default()`]. This does not affect the safety checks controlled by [`Force`].
    pub fn options(mut self, options: Options) -> Self {
        self.options = options;
        self
    }

    /// Validate and remove this linked worktree.
    ///
    /// The main worktree cannot be prepared for removal or removed. With [`Force::Never`], modified or untracked
    /// files and initialized submodules prevent removal. [`Force::DiscardChanges`] permits their deletion, while
    /// a locked worktree additionally requires [`Force::OverrideLock`].
    ///
    /// The worktree's attached branch, if any, is retained.
    ///
    /// `status.showUntrackedFiles` does not affect the untracked-file safety check.
    ///
    /// The calling repository's own linked worktree may also be removed. Callers doing so should leave the
    /// checkout before removal, particularly on Windows where a process working directory can prevent deletion.
    ///
    /// Recursive removal uses [`Options::default()`] unless overridden with [`Target::options()`].
    /// See [`gix_worktree::remove()`] for details about recursive removal, retries, and partial failures.
    ///
    /// # Selecting a worktree
    ///
    /// Obtain a target with [`Repository::prepare_remove_worktree()`][crate::Repository::prepare_remove_worktree()],
    /// which accepts an absolute or relative worktree path or a unique suffix of whole path components.
    /// Relative target paths use the process's current directory, while registered paths use the directory
    /// captured when the repository was opened.
    /// On Windows, existing paths also match their equivalent short-name and verbatim forms.
    /// The returned target can be inspected with [`Target::repository()`] before it is consumed by this method.
    ///
    /// # Examples
    ///
    /// Suppose the main worktree is `/projects/main`, the linked worktrees are `/projects/feature/topic`
    /// and `/projects/release/topic`, and the current directory is `/projects/main`.
    /// These targets all select `/projects/feature/topic` without removing it:
    ///
    /// ```no_run
    /// # fn example(repo: &gix::Repository) -> gix::Result<()> {
    /// // Absolute and relative paths to the linked worktree.
    /// repo.prepare_remove_worktree("/projects/feature/topic")?;
    /// repo.prepare_remove_worktree("../feature/topic")?;
    ///
    /// // A unique suffix of whole path components, followed by inspection.
    /// let target = repo.prepare_remove_worktree("feature/topic")?;
    /// let linked_repo = target.repository()?;
    /// # Ok(())
    /// # }
    /// ```
    ///
    /// Here, `"topic"` matches both linked worktrees and reports [`Error::Ambiguous`]; use `"feature/topic"`
    /// or `"release/topic"` to disambiguate. A partial component such as `"opic"` does not match either worktree.
    pub fn remove<P>(self, force: Force, mut progress: P) -> Result<()>
    where
        P: NestedProgress,
        P::SubProgress: NestedProgress + 'static,
    {
        let Target {
            proxy,
            base: work_dir,
            options,
        } = self;
        let work_dir = proxy.parent.current_dir().join(work_dir);
        let mut validation = progress.add_child("validate");
        validation.init(Some(1), gix_utils::progress::count("worktree"));
        let git_dir = proxy.parent.current_dir().join(proxy.git_dir());
        let ignore_case = proxy.parent.config.ignore_case;

        if force != Force::OverrideLock && proxy.is_locked() {
            bail!(Error::Locked {
                path: work_dir,
                reason: proxy.lock_reason(),
            });
        }

        match std::fs::symlink_metadata(&work_dir) {
            Err(err) if gix_fs::io_err::is_not_found(err.kind(), err.raw_os_error()) => {}
            Err(err) => {
                bail!(err.and_raise(message("Could not read the location of a linked worktree")));
            }
            Ok(_) => {
                validate_backlink(&work_dir, &git_dir, ignore_case)?;
                if !force.discards_changes() {
                    let linked_repo = proxy
                        .into_repo_with_possibly_inaccessible_worktree()
                        .or_raise(|| message("Could not open the linked worktree repository"))?;
                    if has_populated_submodule(&linked_repo)? {
                        bail!(Error::ContainsSubmodule { path: work_dir });
                    }
                    let dirwalk_options = linked_repo
                        .dirwalk_options()
                        .or_raise(|| message("Could not prepare the linked worktree directory walk"))?;
                    let status = validation.add_child("status");
                    let mut iter = linked_repo
                        .status(status)
                        .or_raise(|| message("Could not prepare status for the linked worktree"))?
                        .index_worktree_options_mut(|opts| {
                            // Restore the walk even if status.showUntrackedFiles=no disabled it.
                            opts.dirwalk_options = Some(dirwalk_options);
                        })
                        .untracked_files(crate::status::UntrackedFiles::Files)
                        .index_worktree_submodules(crate::status::Submodule::AsConfigured { check_dirty: true })
                        .into_iter(None)
                        .or_raise(|| message("Could not inspect the linked worktree status"))?;
                    if iter
                        .next()
                        .transpose()
                        .or_raise(|| message("Could not inspect a linked worktree status item"))?
                        .is_some()
                    {
                        bail!(Error::Dirty { path: work_dir });
                    }
                }
            }
        }
        validation.inc();
        drop(validation);

        gix_worktree::remove(work_dir, git_dir, progress, options)
            .or_raise(|| message("Could not remove the linked worktree"))?;
        Ok(())
    }
}

impl crate::worktree::Proxy<'_> {
    /// Remove this linked worktree at its registered checkout path, using [`Options::default()`].
    ///
    /// No path or suffix lookup is performed. See [`Target::remove()`] for removal behavior and examples.
    pub fn remove<P>(self, force: Force, progress: P) -> Result<()>
    where
        P: NestedProgress,
        P::SubProgress: NestedProgress + 'static,
    {
        let base = self
            .base()
            .or_raise(|| message("Could not read the linked worktree checkout location"))?;
        Target {
            proxy: self,
            base,
            options: Options::default(),
        }
        .remove(force, progress)
    }
}

/// A rejection reported while preparing or removing a linked worktree, as described by [`Target::remove()`].
/// These errors are retained in the returned error chain for callers to inspect.
///
/// Missing targets are classified as [`gix_error::Class::NotFound`], mismatched backlinks as
/// [`gix_error::Class::Corruption`], and locked, dirty, or populated-submodule worktrees as
/// [`gix_error::Class::Conflict`]. Empty, ambiguous, or main-worktree targets are invalid input and
/// classified as [`gix_error::Class::Validation`].
#[derive(Debug)]
#[expect(missing_docs)]
pub enum Error {
    EmptyTarget,
    NotFound {
        target: std::path::PathBuf,
    },
    Ambiguous {
        target: std::path::PathBuf,
        candidates: Vec<std::path::PathBuf>,
    },
    MainWorktree {
        path: std::path::PathBuf,
    },
    Locked {
        path: std::path::PathBuf,
        reason: Option<BString>,
    },
    BacklinkMismatch {
        path: std::path::PathBuf,
        expected: std::path::PathBuf,
        actual: std::path::PathBuf,
    },
    ContainsSubmodule {
        path: std::path::PathBuf,
    },
    Dirty {
        path: std::path::PathBuf,
    },
}

impl std::fmt::Display for Error {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        fn display_reason(reason: Option<&BString>) -> String {
            reason.map_or_else(String::new, |reason| format!(": {reason}"))
        }
        match self {
            Error::EmptyTarget => f.write_str("The worktree name or path cannot be empty"),
            Error::NotFound { target } => write!(f, "\"{}\" is not a registered worktree", target.display()),
            Error::Ambiguous { target, candidates } => {
                write!(f, "\"{}\" is ambiguous and matches {candidates:?}", target.display())
            }
            Error::MainWorktree { path } => write!(f, "The main worktree \"{}\" cannot be removed", path.display()),
            Error::Locked { path, reason } => write!(
                f,
                "The worktree \"{}\" is locked{}",
                path.display(),
                display_reason(reason.as_ref())
            ),
            Error::BacklinkMismatch { path, expected, actual } => write!(
                f,
                "The .git file at \"{}\" points to \"{}\", not \"{}\"",
                path.display(),
                actual.display(),
                expected.display()
            ),
            Error::ContainsSubmodule { path } => {
                write!(f, "The worktree '{}' contains an initialized submodule", path.display())
            }
            Error::Dirty { path } => write!(
                f,
                "The worktree \"{}\" contains modified or untracked files",
                path.display()
            ),
        }
    }
}

impl std::error::Error for Error {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        Some(match self {
            Error::NotFound { .. } => const { &ClassificationMarker::NOT_FOUND },
            Error::BacklinkMismatch { .. } => const { &ClassificationMarker::CORRUPTION },
            Error::EmptyTarget | Error::Ambiguous { .. } | Error::MainWorktree { .. } => {
                const { &ClassificationMarker::VALIDATION }
            }
            Error::Locked { .. } | Error::ContainsSubmodule { .. } | Error::Dirty { .. } => {
                const { &ClassificationMarker::CONFLICT }
            }
        })
    }
}

impl crate::Repository {
    /// Resolve `target` for later inspection and removal.
    ///
    /// See [`Target::remove()`] for target selection, removal behavior, and examples.
    pub fn prepare_remove_worktree(&self, target: impl AsRef<Path>) -> Result<Target<'_>> {
        resolve(self, target.as_ref())
    }

    /// Resolve `target` and remove the linked worktree using [`Options::default()`].
    ///
    /// Combines [`Self::prepare_remove_worktree()`] with [`Target::remove()`], which documents target selection,
    /// removal behavior, and examples.
    pub fn remove_worktree<P>(&self, target: impl AsRef<Path>, force: Force, progress: P) -> Result<()>
    where
        P: NestedProgress,
        P::SubProgress: NestedProgress + 'static,
    {
        self.prepare_remove_worktree(target)?.remove(force, progress)
    }
}

fn resolve<'repo>(repo: &'repo crate::Repository, target: &Path) -> Result<Target<'repo>> {
    enum Match<'repo> {
        Main(std::path::PathBuf),
        Linked(Target<'repo>),
    }
    if target.as_os_str().is_empty() {
        bail!(Error::EmptyTarget);
    }
    let main = repo
        .main_repo()
        .or_raise(|| message("Could not open the linked worktree repository"))?
        .workdir()
        .map(Path::to_owned);
    let ignore_case = repo.config.ignore_case;
    let mut linked = Vec::new();
    for proxy in repo
        .worktrees()
        .or_raise(|| message("Could not list linked worktrees"))?
    {
        let Ok(base) = proxy.base() else { continue };
        linked.push(Target {
            proxy,
            base,
            options: Options::default(),
        });
    }

    let mut suffix_matches = Vec::new();
    if let Some(path) = main.as_ref()
        && path_ends_with(path, target, ignore_case)
    {
        suffix_matches.push(Match::Main(path.clone()));
    }
    for candidate in &linked {
        if path_ends_with(&candidate.base, target, ignore_case) {
            suffix_matches.push(Match::Linked(Target {
                proxy: candidate.proxy.clone(),
                base: candidate.base.clone(),
                options: candidate.options,
            }));
        }
    }
    if suffix_matches.len() == 1 {
        return match suffix_matches.pop().expect("exactly one match") {
            Match::Main(path) => Err(Error::MainWorktree { path }.raise()),
            Match::Linked(candidate) => Ok(candidate),
        };
    }

    let resolved_target =
        gix_path::realpath(target).or_raise(|| message!("Could not resolve worktree path \"{}\"", target.display()))?;
    let mut exact_matches = Vec::new();
    if let Some(path) = main {
        let resolved = gix_path::realpath_opts(&path, repo.current_dir(), MAX_SYMLINKS)
            .or_raise(|| message!("Could not resolve worktree path \"{}\"", path.display()))?;
        if path_eq(&resolved, &resolved_target, ignore_case) {
            exact_matches.push(Match::Main(path));
        }
    }
    for candidate in linked {
        let resolved = gix_path::realpath_opts(&candidate.base, repo.current_dir(), MAX_SYMLINKS)
            .or_raise(|| message!("Could not resolve worktree path \"{}\"", candidate.base.display()))?;
        if path_eq(&resolved, &resolved_target, ignore_case) {
            exact_matches.push(Match::Linked(candidate));
        }
    }

    match exact_matches.len() {
        0 if suffix_matches.is_empty() => Err(Error::NotFound {
            target: target.to_owned(),
        }
        .raise()),
        0 => Err(Error::Ambiguous {
            target: target.to_owned(),
            candidates: suffix_matches
                .into_iter()
                .map(|candidate| match candidate {
                    Match::Main(path) => path,
                    Match::Linked(candidate) => candidate.base,
                })
                .collect(),
        }
        .raise()),
        1 => match exact_matches.pop().expect("exactly one match") {
            Match::Main(path) => Err(Error::MainWorktree { path }.raise()),
            Match::Linked(candidate) => Ok(candidate),
        },
        _ => Err(Error::Ambiguous {
            target: target.to_owned(),
            candidates: exact_matches
                .into_iter()
                .map(|candidate| match candidate {
                    Match::Main(path) => path,
                    Match::Linked(candidate) => candidate.base,
                })
                .collect(),
        }
        .raise()),
    }
}

fn validate_backlink(work_dir: &Path, git_dir: &Path, ignore_case: bool) -> Result<()> {
    let path = work_dir.join(gix_discover::DOT_GIT_DIR);
    let actual = gix_discover::path::from_gitdir_file(&path)
        .or_raise(|| message!("Could not read the .git file at \"{}\"", path.display()))?;
    let actual = gix_path::realpath(&actual)
        .or_raise(|| message!("Could not resolve the private Git directory \"{}\"", actual.display()))?;
    let expected = gix_path::realpath(git_dir)
        .or_raise(|| message!("Could not resolve the private Git directory \"{}\"", git_dir.display()))?;
    if !path_eq(&actual, &expected, ignore_case) {
        bail!(Error::BacklinkMismatch { path, expected, actual });
    }
    Ok(())
}

fn path_eq(left: &Path, right: &Path, ignore_case: bool) -> bool {
    if left.components().count() == right.components().count() && path_ends_with(left, right, ignore_case) {
        return true;
    }
    #[cfg(windows)]
    if let (Ok(left), Ok(right)) = (left.canonicalize(), right.canonicalize()) {
        // Windows short names and verbatim paths can refer to the same existing directory.
        return left == right;
    }
    false
}

fn path_ends_with(path: &Path, suffix: &Path, ignore_case: bool) -> bool {
    if !ignore_case {
        return path.ends_with(suffix);
    }
    let mut path = path.components().rev();
    suffix.components().rev().all(|suffix| {
        path.next().is_some_and(|path| {
            path.as_os_str()
                .as_encoded_bytes()
                .eq_ignore_ascii_case(suffix.as_os_str().as_encoded_bytes())
        })
    })
}

/// Return whether `repo` has submodule state that blocks removal with [`Force::Never`].
///
/// A `modules` directory in the private Git directory is conservatively considered sufficient, even if empty.
/// Otherwise, check index gitlinks for an existing `.git` entry in their checkout. Unpopulated gitlinks alone
/// do not count. Submodule cleanliness and ignore settings are not consulted.
fn has_populated_submodule(repo: &crate::Repository) -> Result<bool> {
    if repo.git_dir().join("modules").is_dir() {
        return Ok(true);
    }
    let index = repo
        .index_or_empty()
        .or_raise(|| message("Could not open the linked worktree index"))?;
    if let Some(work_dir) = repo.workdir() {
        for entry in index
            .entries()
            .iter()
            .filter(|entry| entry.mode == gix_index::entry::Mode::COMMIT)
        {
            if work_dir
                .join(gix_path::from_bstr(entry.path(&index))?)
                .join(".git")
                .exists()
            {
                return Ok(true);
            }
        }
    }
    Ok(false)
}