Skip to main content

vcs_git/
specs.rs

1//! Command specifications, validated revision types, and capability metadata.
2
3use super::*;
4
5/// Options for [`GitApi::sparse_checkout_set`] (`git sparse-checkout set`).
6///
7/// `#[non_exhaustive]`, so build it through [`SparseCheckoutSet::new`] and the
8/// [`non_cone`](SparseCheckoutSet::non_cone) setter rather than a bare boolean
9/// that would make the selected matching mode ambiguous at the call site.
10#[derive(Debug, Clone, PartialEq, Eq)]
11#[non_exhaustive]
12pub struct SparseCheckoutSet {
13    /// Directories in cone mode, or gitignore-style patterns in non-cone mode.
14    /// The list must contain at least one non-empty, non-flag-like value.
15    pub patterns: Vec<String>,
16    /// Whether git should interpret `patterns` as cone directories (`--cone`).
17    pub cone: bool,
18}
19
20impl SparseCheckoutSet {
21    /// Set sparse checkout to `patterns` in cone mode (`--cone`).
22    pub fn new(patterns: impl IntoIterator<Item = impl Into<String>>) -> Self {
23        Self {
24            patterns: patterns.into_iter().map(Into::into).collect(),
25            cone: true,
26        }
27    }
28
29    /// Interpret the supplied values as gitignore-style patterns
30    /// (`--no-cone`) instead of cone directories.
31    pub fn non_cone(mut self) -> Self {
32        self.cone = false;
33        self
34    }
35}
36
37/// Options for [`GitApi::worktree_add`] (`git worktree add`).
38///
39/// `#[non_exhaustive]`, so build it through [`WorktreeAdd::checkout`] /
40/// [`WorktreeAdd::create_branch`] rather than a struct literal.
41#[derive(Debug, Clone)]
42#[non_exhaustive]
43pub struct WorktreeAdd {
44    /// Filesystem path for the new worktree.
45    pub path: PathBuf,
46    /// Create and check out this new branch (`-b <name>`); `None` checks out an
47    /// existing ref.
48    pub new_branch: Option<RefName>,
49    /// The commit/branch to base the worktree on; `None` defaults to `HEAD`.
50    pub commitish: Option<RevSpec>,
51    /// Register the worktree without populating its files (`--no-checkout`) — the
52    /// caller fills the working tree itself (e.g. a copy-on-write clone).
53    pub no_checkout: bool,
54}
55
56impl WorktreeAdd {
57    /// A worktree at `path` checking out an existing `commitish` (e.g. a branch):
58    /// `git worktree add <path> <commitish>`.
59    pub fn checkout(path: impl Into<PathBuf>, commitish: RevSpec) -> Self {
60        Self {
61            path: path.into(),
62            new_branch: None,
63            commitish: Some(commitish),
64            no_checkout: false,
65        }
66    }
67
68    /// A worktree at `path` creating a new branch `name` based on `commitish`:
69    /// `git worktree add -b <name> <path> <commitish>`.
70    pub fn create_branch(path: impl Into<PathBuf>, name: RefName, commitish: RevSpec) -> Self {
71        Self {
72            path: path.into(),
73            new_branch: Some(name),
74            commitish: Some(commitish),
75            no_checkout: false,
76        }
77    }
78
79    /// Register the worktree without checking out its files (`--no-checkout`),
80    /// for a caller that populates the working tree itself.
81    pub fn no_checkout(mut self) -> Self {
82        self.no_checkout = true;
83        self
84    }
85}
86
87/// Options for [`GitApi::push`] (`git push`).
88///
89/// `#[non_exhaustive]`, so build it through [`GitPush::branch`] /
90/// [`GitPush::refspec`] rather than a struct literal.
91#[derive(Debug, Clone)]
92#[non_exhaustive]
93pub struct GitPush {
94    /// Remote to push to (defaults to `origin`).
95    pub remote: String,
96    /// The refspec — a bare branch name, or `local:remote_branch`.
97    pub refspec: String,
98    /// Set the pushed branch as the upstream (`-u`).
99    pub set_upstream: bool,
100}
101
102impl GitPush {
103    /// Push branch `name` to `origin` under the same name (`git push origin <name>`).
104    pub fn branch(name: RefName) -> Self {
105        Self {
106            remote: "origin".to_string(),
107            refspec: name.as_str().to_string(),
108            set_upstream: false,
109        }
110    }
111
112    /// Push `local` to a differently-named `remote_branch`
113    /// (`git push origin <local>:<remote_branch>`). Both sides are validated
114    /// [`RefName`]s, so the single `:` is always the API-inserted separator — a
115    /// caller cannot smuggle an extra ref or a force (`+`) through them.
116    pub fn refspec(local: &RefName, remote_branch: &RefName) -> Self {
117        Self {
118            remote: "origin".to_string(),
119            refspec: format!("{}:{}", local.as_str(), remote_branch.as_str()),
120            set_upstream: false,
121        }
122    }
123
124    /// Push to a non-default remote.
125    pub fn remote(mut self, remote: impl Into<String>) -> Self {
126        self.remote = remote.into();
127        self
128    }
129
130    /// Record the pushed branch as the local branch's upstream (`-u`).
131    pub fn set_upstream(mut self) -> Self {
132        self.set_upstream = true;
133        self
134    }
135}
136
137/// The partial-clone object filter for [`GitApi::clone_repo`] (`git clone
138/// --filter=<value>`).
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140#[non_exhaustive]
141pub enum CloneFilter {
142    /// Omit blobs until they are needed (`--filter=blob:none`).
143    BlobNone,
144    /// Omit trees until they are needed (`--filter=tree:0`).
145    TreeZero,
146}
147
148impl CloneFilter {
149    pub(crate) fn cli_value(self) -> &'static str {
150        match self {
151            Self::BlobNone => "blob:none",
152            Self::TreeZero => "tree:0",
153        }
154    }
155}
156
157/// Options for [`GitApi::clone_repo`] (`git clone`).
158///
159/// `#[non_exhaustive]`, so build it through [`CloneSpec::new`] and the chained
160/// setters rather than a struct literal.
161#[derive(Debug, Clone, Default)]
162#[non_exhaustive]
163pub struct CloneSpec {
164    /// Check out this branch instead of the remote's default (`--branch`).
165    pub branch: Option<String>,
166    /// Shallow-clone to this many commits (`--depth`). git silently ignores
167    /// the flag for a plain local-path source (warns, still clones fully);
168    /// use a `file://` URL to shallow-clone locally.
169    pub depth: Option<u32>,
170    /// Use a partial-clone object filter (`--filter=blob:none` or
171    /// `--filter=tree:0`).
172    pub filter: Option<CloneFilter>,
173    /// Limit the clone to the selected branch (`--single-branch`).
174    pub single_branch: bool,
175    /// Name the remote created by the clone instead of `origin` (`--origin`).
176    /// The value is checked before spawning git.
177    pub origin: Option<String>,
178    /// Create a bare repository (`--bare`).
179    pub bare: bool,
180}
181
182impl CloneSpec {
183    /// A plain full clone of the remote's default branch.
184    pub fn new() -> Self {
185        Self::default()
186    }
187
188    /// Check out `branch` instead of the remote's default (`--branch`).
189    pub fn branch(mut self, branch: impl Into<String>) -> Self {
190        self.branch = Some(branch.into());
191        self
192    }
193
194    /// Shallow-clone to `depth` commits (`--depth`); see the field doc for the
195    /// local-path caveat.
196    pub fn depth(mut self, depth: u32) -> Self {
197        self.depth = Some(depth);
198        self
199    }
200
201    /// Use a partial clone filter (`--filter=<value>`).
202    pub fn filter(mut self, filter: CloneFilter) -> Self {
203        self.filter = Some(filter);
204        self
205    }
206
207    /// Restrict the clone to the selected branch (`--single-branch`).
208    pub fn single_branch(mut self) -> Self {
209        self.single_branch = true;
210        self
211    }
212
213    /// Name the clone's remote `name` instead of `origin` (`--origin <name>`).
214    /// The value is rejected before spawning if it is empty, flag-like, or
215    /// contains an embedded NUL.
216    pub fn origin(mut self, name: impl Into<String>) -> Self {
217        self.origin = Some(name.into());
218        self
219    }
220
221    /// Clone as a bare repository (`--bare`).
222    pub fn bare(mut self) -> Self {
223        self.bare = true;
224        self
225    }
226}
227
228/// Options for [`GitApi::commit_paths`] (`git commit --only`).
229///
230/// `#[non_exhaustive]`, so build it through [`CommitPaths::new`] and the chained
231/// setters rather than a struct literal.
232#[derive(Debug, Clone)]
233#[non_exhaustive]
234pub struct CommitPaths {
235    /// The exact paths whose working-tree content to commit (`--only -- <paths>`).
236    pub paths: Vec<PathBuf>,
237    /// The commit message (`-m`).
238    pub message: String,
239    /// Amend the previous commit instead of creating a new one (`--amend`).
240    pub amend: bool,
241}
242
243impl CommitPaths {
244    /// Commit exactly `paths`' working-tree content with `message`
245    /// (`git commit -m <message> --only -- <paths>`).
246    pub fn new(
247        paths: impl IntoIterator<Item = impl Into<PathBuf>>,
248        message: impl Into<String>,
249    ) -> Self {
250        Self {
251            paths: paths.into_iter().map(Into::into).collect(),
252            message: message.into(),
253            amend: false,
254        }
255    }
256
257    /// Amend the previous commit instead of creating a new one (`--amend`).
258    pub fn amend(mut self) -> Self {
259        self.amend = true;
260        self
261    }
262}
263
264/// Partial [`MergeCheck`] — names the branch being tested; chain
265/// [`into_base`](MergeCheckPartial::into_base) to name the base it must be merged into.
266#[derive(Debug, Clone)]
267pub struct MergeCheckPartial {
268    branch: RefName,
269}
270
271impl MergeCheckPartial {
272    /// The base commit-ish `branch` should be fully merged **into**.
273    pub fn into_base(self, base: RevSpec) -> MergeCheck {
274        MergeCheck {
275            branch: self.branch,
276            base,
277        }
278    }
279}
280
281/// A "is `branch` fully merged into `base`?" check for [`GitApi::is_merged`].
282///
283/// Built as `MergeCheck::branch(RefName::new("feature")?).into_base(RevSpec::new("main")?)` — the two same-typed
284/// refs are named across **two** builder steps, so they can't be silently transposed
285/// (a swap would *invert* the answer). `#[non_exhaustive]`.
286#[derive(Debug, Clone, PartialEq, Eq)]
287#[non_exhaustive]
288pub struct MergeCheck {
289    /// The branch/ref being tested for having been merged.
290    pub branch: RefName,
291    /// The base commit-ish it should be fully merged into.
292    pub base: RevSpec,
293}
294
295impl MergeCheck {
296    /// Name the `branch` to test; chain [`into_base`](MergeCheckPartial::into_base).
297    pub fn branch(name: RefName) -> MergeCheckPartial {
298        MergeCheckPartial { branch: name }
299    }
300}
301
302/// Options for [`GitApi::merge_commit`] (`git merge` that commits the result).
303///
304/// `#[non_exhaustive]`, so build it through [`MergeCommit::branch`] and the
305/// chained setters rather than a struct literal.
306#[derive(Debug, Clone)]
307#[non_exhaustive]
308pub struct MergeCommit {
309    /// The commit-ish to merge in.
310    pub branch: RevSpec,
311    /// Always create a merge commit, even when a fast-forward was possible
312    /// (`--no-ff`).
313    pub no_ff: bool,
314    /// The merge commit message (`-m`); `None` takes the default message
315    /// non-interactively (`--no-edit`).
316    pub message: Option<String>,
317}
318
319impl MergeCommit {
320    /// Merge `target` taking the default merge message non-interactively
321    /// (`git merge --no-edit <target>`).
322    pub fn branch(target: RevSpec) -> Self {
323        Self {
324            branch: target,
325            no_ff: false,
326            message: None,
327        }
328    }
329
330    /// Always create a merge commit, even when a fast-forward was possible
331    /// (`--no-ff`).
332    pub fn no_ff(mut self) -> Self {
333        self.no_ff = true;
334        self
335    }
336
337    /// Use `m` as the merge commit message (`-m`).
338    pub fn message(mut self, m: impl Into<String>) -> Self {
339        self.message = Some(m.into());
340        self
341    }
342}
343
344/// Options for [`GitApi::merge_no_commit`] (`git merge --no-commit`).
345///
346/// `#[non_exhaustive]`, so build it through [`MergeNoCommit::branch`] and the
347/// chained setters rather than a struct literal.
348#[derive(Debug, Clone)]
349#[non_exhaustive]
350pub struct MergeNoCommit {
351    /// The commit-ish to merge in.
352    pub branch: RevSpec,
353    /// Stage the squashed result without recording `MERGE_HEAD` (`--squash`);
354    /// takes precedence over `no_ff` (git rejects the pair).
355    pub squash: bool,
356    /// Always record a real (abortable) merge, even when a fast-forward was
357    /// possible (`--no-ff`).
358    pub no_ff: bool,
359}
360
361impl MergeNoCommit {
362    /// Merge `target` but stop before committing (`git merge --no-commit <target>`).
363    pub fn branch(target: RevSpec) -> Self {
364        Self {
365            branch: target,
366            squash: false,
367            no_ff: false,
368        }
369    }
370
371    /// Stage the squashed result without recording `MERGE_HEAD` (`--squash`).
372    pub fn squash(mut self) -> Self {
373        self.squash = true;
374        self
375    }
376
377    /// Always record a real (abortable) merge, even when a fast-forward was
378    /// possible (`--no-ff`).
379    pub fn no_ff(mut self) -> Self {
380        self.no_ff = true;
381        self
382    }
383}
384
385/// Options for [`GitApi::tag_create_annotated`] (`git tag -a`).
386///
387/// `#[non_exhaustive]`, so build it through [`AnnotatedTag::new`] and the chained
388/// setter rather than a struct literal.
389#[derive(Debug, Clone)]
390#[non_exhaustive]
391pub struct AnnotatedTag {
392    /// The tag name.
393    pub name: RefName,
394    /// The tag message (`-m`).
395    pub message: String,
396    /// The revision to tag (`<rev>`); `None` tags `HEAD`.
397    pub rev: Option<RevSpec>,
398}
399
400impl AnnotatedTag {
401    /// An annotated tag `name` with `message` at `HEAD`
402    /// (`git tag -a <name> -m <message>`).
403    pub fn new(name: RefName, message: impl Into<String>) -> Self {
404        Self {
405            name,
406            message: message.into(),
407            rev: None,
408        }
409    }
410
411    /// Tag `r` instead of `HEAD`.
412    pub fn rev(mut self, r: RevSpec) -> Self {
413        self.rev = Some(r);
414        self
415    }
416}
417
418/// Options for [`GitApi::delete_branch`] (`git branch -d`/`-D`).
419///
420/// `#[non_exhaustive]`, so build it through [`BranchDelete::new`] and the chained
421/// [`force`](BranchDelete::force) setter rather than a struct literal — a bare
422/// `bool` at the call site (`delete_branch(name, true)`) doesn't say what `true`
423/// means, and this leaves room to add options without a breaking signature change.
424#[derive(Debug, Clone, PartialEq, Eq)]
425#[non_exhaustive]
426pub struct BranchDelete {
427    /// The local branch name to delete.
428    pub name: RefName,
429    /// Delete even if not fully merged — `git branch -D` vs `-d`.
430    pub force: bool,
431}
432
433impl BranchDelete {
434    /// Delete branch `name`; not forced (git refuses an unmerged branch).
435    pub fn new(name: RefName) -> Self {
436        Self { name, force: false }
437    }
438
439    /// Delete even if not fully merged (`-D`).
440    pub fn force(mut self) -> Self {
441        self.force = true;
442        self
443    }
444}
445
446/// Options for [`GitApi::stash_push`] (`git stash push`).
447///
448/// `#[non_exhaustive]`, so build it through [`StashPush::new`] and the chained
449/// [`include_untracked`](StashPush::include_untracked) setter rather than a bare
450/// `bool` (`stash_push(dir, true)` doesn't say what `true` selects).
451#[derive(Debug, Clone, Default, PartialEq, Eq)]
452#[non_exhaustive]
453pub struct StashPush {
454    /// Also stash untracked files (`--include-untracked`).
455    pub include_untracked: bool,
456}
457
458impl StashPush {
459    /// Stash the tracked working-tree changes only.
460    pub fn new() -> Self {
461        Self::default()
462    }
463
464    /// Also stash untracked files (`--include-untracked`).
465    pub fn include_untracked(mut self) -> Self {
466        self.include_untracked = true;
467        self
468    }
469}
470
471/// How [`Clean`] treats ignored files/directories — the `-x`/`-X` axis of
472/// `git clean`, orthogonal to [`directories`](Clean::directories).
473#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
474#[non_exhaustive]
475pub enum CleanIgnored {
476    /// Leave ignored files alone (git's default): only untracked-and-not-ignored
477    /// entries are candidates.
478    #[default]
479    Exclude,
480    /// Also remove ignored files/directories (`-x`), in addition to the
481    /// ordinary untracked ones.
482    Include,
483    /// Remove **only** ignored files/directories (`-X`) — untracked-but-not-ignored
484    /// entries are left alone.
485    Only,
486}
487
488/// Options for [`GitApi::clean`] (`git clean`) — deletes untracked files from
489/// the working tree.
490///
491/// **Force is a deliberate, explicit call, never a default.** There is no
492/// `Clean::new()` state, nor any other setter, that arms deletion by
493/// itself — only the explicit [`force`](Clean::force) call does, the same
494/// "no bare `bool`, no implied default" pattern [`BranchDelete::force`] and
495/// [`WorktreeRemove::force`] use for their own destructive flag. Independently,
496/// [`GitApi::clean`] itself refuses to run at all — before spawning `git` —
497/// unless the spec picked **either** [`dry_run`](Clean::dry_run) **or**
498/// [`force`](Clean::force): this crate's own guard, so the outcome never
499/// depends on whether the caller's `clean.requireForce` git config happens to
500/// be (mis)set to `false`. When [`dry_run`](Clean::dry_run) is set, `force` is
501/// ignored — dry-run always wins, so it is never possible to accidentally
502/// delete while asking for a preview.
503///
504/// `#[non_exhaustive]`, so build it through [`Clean::new`] and the chained
505/// setters rather than a struct literal.
506#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
507#[non_exhaustive]
508pub struct Clean {
509    /// Actually delete rather than merely report (`--force`/`-f`). Ignored
510    /// when [`dry_run`](Self::dry_run) is also set.
511    pub force: bool,
512    /// Report what *would* be deleted, without deleting anything (`--dry-run`/
513    /// `-n`). Takes priority over [`force`](Self::force).
514    pub dry_run: bool,
515    /// Remove whole untracked directories, not just untracked files (`-d`).
516    pub directories: bool,
517    /// How ignored files/directories are treated; see [`CleanIgnored`].
518    pub ignored: CleanIgnored,
519}
520
521impl Clean {
522    /// A clean spec with neither [`dry_run`](Self::dry_run) nor
523    /// [`force`](Self::force) picked yet — passing this as-is to
524    /// [`GitApi::clean`] is refused before spawning (see the type docs); chain
525    /// one of the two setters before use.
526    pub fn new() -> Self {
527        Self::default()
528    }
529
530    /// Actually delete (`--force`/`-f`) — the one, explicit way to arm
531    /// deletion; see the type docs.
532    pub fn force(mut self) -> Self {
533        self.force = true;
534        self
535    }
536
537    /// Report what would be deleted without deleting anything (`--dry-run`/`-n`).
538    pub fn dry_run(mut self) -> Self {
539        self.dry_run = true;
540        self
541    }
542
543    /// Remove whole untracked directories too (`-d`).
544    pub fn directories(mut self) -> Self {
545        self.directories = true;
546        self
547    }
548
549    /// Also remove ignored files/directories (`-x`), in addition to ordinary
550    /// untracked ones.
551    pub fn include_ignored(mut self) -> Self {
552        self.ignored = CleanIgnored::Include;
553        self
554    }
555
556    /// Remove **only** ignored files/directories (`-X`).
557    pub fn only_ignored(mut self) -> Self {
558        self.ignored = CleanIgnored::Only;
559        self
560    }
561}
562
563/// Options for [`GitApi::worktree_remove`] (`git worktree remove`).
564///
565/// `#[non_exhaustive]`, so build it through [`WorktreeRemove::new`] and the chained
566/// [`force`](WorktreeRemove::force) setter rather than a struct literal — a bare
567/// `bool` (`worktree_remove(path, true)`) doesn't say what `true` means.
568#[derive(Debug, Clone, PartialEq, Eq)]
569#[non_exhaustive]
570pub struct WorktreeRemove {
571    /// The attached worktree path to remove.
572    pub path: PathBuf,
573    /// Remove even when the worktree has uncommitted changes (`--force`).
574    pub force: bool,
575}
576
577impl WorktreeRemove {
578    /// Remove the worktree at `path`; not forced (git refuses a dirty one).
579    pub fn new(path: impl Into<PathBuf>) -> Self {
580        Self {
581            path: path.into(),
582            force: false,
583        }
584    }
585
586    /// Remove even when the worktree has uncommitted changes (`--force`).
587    pub fn force(mut self) -> Self {
588        self.force = true;
589        self
590    }
591}
592
593/// A `git submodule update` specification — checks out the submodules recorded
594/// in the superproject's index to the commits it pins, optionally initializing
595/// (`--init`) and recursing (`--recursive`) first, and optionally scoped to
596/// specific paths. Built fluently; see [`GitApi::submodule_update`].
597///
598/// **This is the one submodule verb that materializes and executes a *different*
599/// (nested) repository's content** — with `init`, it clones/fetches each
600/// submodule from the URL its `.gitmodules` records and checks out its working
601/// tree. Treat those nested repos with the same untrusted-repo caution as the
602/// superproject; see the submodules section of the security guide.
603#[derive(Debug, Clone, PartialEq, Eq, Default)]
604#[non_exhaustive]
605pub struct SubmoduleUpdate {
606    /// Initialize (register + clone) submodules not yet set up (`--init`).
607    pub init: bool,
608    /// Recurse into nested submodules (`--recursive`).
609    pub recursive: bool,
610    /// Create a shallow clone with this history depth (`--depth <n>`); `None`
611    /// leaves the depth unset (full history).
612    pub depth: Option<u32>,
613    /// Restrict the update to these repo-relative submodule paths; empty means
614    /// every submodule. Passed after a `--` terminator so a path can never be
615    /// parsed as a flag.
616    pub paths: Vec<String>,
617}
618
619impl SubmoduleUpdate {
620    /// A plain update (no `--init`/`--recursive`/`--depth`, all submodules).
621    pub fn new() -> Self {
622        Self::default()
623    }
624
625    /// Register and clone submodules that are not yet initialized (`--init`).
626    pub fn init(mut self) -> Self {
627        self.init = true;
628        self
629    }
630
631    /// Recurse into nested submodules (`--recursive`).
632    pub fn recursive(mut self) -> Self {
633        self.recursive = true;
634        self
635    }
636
637    /// Make a shallow checkout with history `depth` (`--depth <n>`).
638    pub fn depth(mut self, depth: u32) -> Self {
639        self.depth = Some(depth);
640        self
641    }
642
643    /// Scope the update to one submodule `path` (repeatable). With no path set,
644    /// the update covers every submodule.
645    pub fn path(mut self, path: impl Into<String>) -> Self {
646        self.paths.push(path.into());
647        self
648    }
649
650    /// Scope the update to several submodule `paths` (appended to any already set).
651    pub fn paths(mut self, paths: impl IntoIterator<Item = impl Into<String>>) -> Self {
652        self.paths.extend(paths.into_iter().map(Into::into));
653        self
654    }
655}
656
657/// A validated git reference name (branch/tag/remote-tracking ref). Every
658/// [`GitApi`] operation that names a branch, tag, or ref to **create, delete,
659/// rename, or look up by exact name** takes a `RefName` (directly or inside its
660/// options struct), so a name from untrusted input (UIs, bots, agents) is
661/// validated once, at construction, and the type — not an internal guard — is
662/// the argv-injection barrier from then on. For a general commit-ish or range
663/// (`checkout`, `reset_hard`, `log`, `diff` ranges, …) use the more permissive
664/// [`RevSpec`] instead.
665///
666/// Rules follow the load-bearing core of `git check-ref-format`: non-empty,
667/// no leading `-` or `.`, no `..`, no control characters or space, none of
668/// `~ ^ : ? * [ \`, and no empty slash-separated components. Each component
669/// must not start or end with `.` or end with `.lock`.
670/// The validator also rejects a trailing `.`, any `@{` sequence, and the
671/// standalone `@`. A rejected name is an
672/// [`vcs_cli_support::is_invalid_input`] failure.
673#[derive(Debug, Clone, PartialEq, Eq, Hash)]
674pub struct RefName(String);
675
676impl RefName {
677    /// Validate `name` as a reference name.
678    pub fn new(name: impl Into<String>) -> Result<Self> {
679        let name = name.into();
680        let bad = name.is_empty()
681            || name.starts_with('-')
682            || name.starts_with('.')
683            || name.ends_with('/')
684            || name.ends_with('.')
685            || name.ends_with(".lock")
686            || name.split('/').any(|component| {
687                component.is_empty()
688                    || component.starts_with('.')
689                    || component.ends_with('.')
690                    || component.ends_with(".lock")
691            })
692            || name.contains("..")
693            || name.contains("@{")
694            || name == "@"
695            || name
696                .chars()
697                .any(|c| c.is_control() || " ~^:?*[\\".contains(c));
698        if bad {
699            return Err(Error::spawn(
700                BINARY,
701                std::io::Error::new(
702                    std::io::ErrorKind::InvalidInput,
703                    format!("invalid git reference name: {name:?}"),
704                ),
705            ));
706        }
707        Ok(RefName(name))
708    }
709
710    /// The validated name.
711    pub fn as_str(&self) -> &str {
712        &self.0
713    }
714}
715
716impl std::fmt::Display for RefName {
717    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
718        f.write_str(&self.0)
719    }
720}
721
722/// A validated revision/range expression (`HEAD~2`, `main..feature`). Every
723/// [`GitApi`] operation that resolves a general **commit-ish or range** takes a
724/// `RevSpec`, so an untrusted revision is validated once, at construction.
725/// Deliberately *minimal* — git's revision grammar is too rich to validate
726/// here — it only guarantees the expression is non-empty and cannot be parsed
727/// as a flag (no leading `-`). For a value that must be a genuine ref **name**
728/// (to create/delete/rename a branch or tag) use the stricter [`RefName`]. A
729/// rejected expression is an [`vcs_cli_support::is_invalid_input`] failure.
730#[derive(Debug, Clone, PartialEq, Eq, Hash)]
731pub struct RevSpec(String);
732
733impl RevSpec {
734    /// Validate `rev` as a revision/range expression (non-empty, no leading `-`).
735    pub fn new(rev: impl Into<String>) -> Result<Self> {
736        let rev = rev.into();
737        reject_flag_like("revision", &rev)?;
738        Ok(RevSpec(rev))
739    }
740
741    /// The validated expression.
742    pub fn as_str(&self) -> &str {
743        &self.0
744    }
745}
746
747impl std::fmt::Display for RevSpec {
748    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
749        f.write_str(&self.0)
750    }
751}
752
753impl std::str::FromStr for RefName {
754    type Err = Error;
755    fn from_str(s: &str) -> Result<Self> {
756        Self::new(s)
757    }
758}
759
760impl std::str::FromStr for RevSpec {
761    type Err = Error;
762    fn from_str(s: &str) -> Result<Self> {
763        Self::new(s)
764    }
765}
766
767#[cfg(test)]
768mod tests {
769    use super::{RefName, RevSpec};
770
771    fn assert_invalid_ref_name(name: &str) {
772        let err = RefName::new(name).expect_err(&format!("{name:?} must be rejected"));
773        assert!(
774            vcs_cli_support::is_invalid_input(&err),
775            "{name:?} must classify as invalid input: {err:?}"
776        );
777    }
778
779    #[test]
780    fn ref_name_rejects_git_check_ref_format_boundaries() {
781        for name in [
782            "",
783            "-feature",
784            ".feature",
785            "feature/",
786            "feature.lock",
787            "feature..name",
788            "feature name",
789            "feature~name",
790            "feature^name",
791            "feature:name",
792            "feature?name",
793            "feature*name",
794            "feature[name",
795            "feature\\name",
796            "feature\0name",
797            "feature.",
798            "feature@{upstream}",
799            "@",
800        ] {
801            assert_invalid_ref_name(name);
802        }
803    }
804
805    #[test]
806    fn ref_name_rejects_invalid_slash_separated_components() {
807        for name in [
808            "/feature",
809            "feature//name",
810            "feature/.hidden",
811            "feature/name.",
812            "feature.lock/name",
813            "feature/name.lock",
814        ] {
815            assert_invalid_ref_name(name);
816        }
817    }
818
819    #[test]
820    fn ref_name_accepts_valid_names_without_widening_revspec() {
821        for name in ["main", "feature/login", "release/v1.2.3", "feature@review"] {
822            assert_eq!(RefName::new(name).unwrap().as_str(), name);
823        }
824
825        // These values remain valid revision expressions even though some are
826        // deliberately invalid as concrete ref names.
827        for rev in ["main..feature", "feature@{upstream}", "@"] {
828            assert_eq!(RevSpec::new(rev).unwrap().as_str(), rev);
829        }
830    }
831}
832
833/// The typed result of one `git bisect` classification step.
834///
835/// The `bisect_start`, `bisect_good`, `bisect_bad`, and `bisect_skip` methods
836/// all return this value after Git has classified the current checkout. A
837/// [`NextCandidate`](Self::NextCandidate) means that Git moved the worktree to
838/// the returned revision and the consumer should run its test there. A
839/// [`FirstBad`](Self::FirstBad) means that Git finished the search; the
840/// returned revision is the first bad commit and no further classification is
841/// needed. The consumer owns the test loop and should call
842/// [`GitApi::bisect_reset`] when it is done, including on an error or
843/// cancellation.
844///
845/// This type is Git-only. It is deliberately not part of `vcs-core` because
846/// Jujutsu has no corresponding bisect command.
847#[derive(Debug, Clone, PartialEq, Eq)]
848#[non_exhaustive]
849pub enum BisectStep {
850    /// Git checked out another revision for the consumer to test.
851    NextCandidate {
852        /// The revision Git selected and checked out.
853        revision: RevSpec,
854    },
855    /// Git completed the search and identified the first bad revision.
856    FirstBad {
857        /// The first bad revision reported by Git.
858        revision: RevSpec,
859    },
860}
861
862impl BisectStep {
863    /// The revision Git selected for this step.
864    pub fn revision(&self) -> &RevSpec {
865        match self {
866            Self::NextCandidate { revision } | Self::FirstBad { revision } => revision,
867        }
868    }
869
870    /// Whether this step completed the bisect search with a first bad commit.
871    pub fn is_first_bad(&self) -> bool {
872        matches!(self, Self::FirstBad { .. })
873    }
874}
875
876/// Backward-readable alias for [`BisectStep`]. Prefer `BisectStep` in new code.
877pub type BisectResult = BisectStep;
878
879/// What [`GitApi::checkout`] switches to: a validated ref/revision, or git's `-`
880/// "previous branch" shortcut.
881///
882/// `-` is the one place a leading-`-` token is legitimate — it is git's
883/// `@{-1}` shorthand, not caller-controlled argv — so it is modelled as a
884/// distinct [`Previous`](CheckoutTarget::Previous) variant emitting a fixed
885/// literal, rather than punching a hole in [`RevSpec`]'s no-leading-`-`
886/// invariant (which the other commit-ish operations rely on).
887#[derive(Debug, Clone, PartialEq, Eq)]
888pub enum CheckoutTarget {
889    /// Check out this validated ref or revision.
890    Ref(RevSpec),
891    /// Check out the previous branch (`git checkout -`).
892    Previous,
893}
894
895impl CheckoutTarget {
896    /// Check out a validated ref/revision.
897    pub fn rev(rev: RevSpec) -> Self {
898        Self::Ref(rev)
899    }
900
901    /// Check out the previous branch (`git checkout -`).
902    pub fn previous() -> Self {
903        Self::Previous
904    }
905
906    /// The single argv token this target expands to.
907    pub(super) fn as_arg(&self) -> &str {
908        match self {
909            Self::Ref(rev) => rev.as_str(),
910            Self::Previous => "-",
911        }
912    }
913}
914
915impl From<RevSpec> for CheckoutTarget {
916    fn from(rev: RevSpec) -> Self {
917        Self::Ref(rev)
918    }
919}
920
921/// What the installed `git` binary supports, probed via
922/// [`GitApi::capabilities`]. A value type — the client holds no state, so
923/// probe once and keep the result (callers cache it).
924#[derive(Debug, Clone, Copy, PartialEq, Eq)]
925#[non_exhaustive]
926pub struct GitCapabilities {
927    /// The binary's parsed version.
928    pub version: GitVersion,
929}
930
931/// The oldest git this crate is written against — **2.31**, the highest version its
932/// own argv actually requires (validated on 2.54). `harden()` pins config through
933/// `GIT_CONFIG_COUNT`/`_KEY_n`/`_VALUE_n` (added in **2.31**); `branch_status`/`snapshot`
934/// read `status --porcelain=v2` (2.11) and `switch_with_stash` uses `stash push` (2.13),
935/// all below 2.31. Gating on the real minor floor makes [`ensure_supported`] catch a
936/// too-old git with a clear message rather than letting it pass and then fail later with
937/// a cryptic argv error — the M29 fix (the previous gate was major-only, so 2.7 "passed"
938/// then broke). (Contrast vcs-jj, whose floor is precise per its empirically-validated
939/// parser release.)
940const MIN_SUPPORTED_MAJOR: u64 = 2;
941const MIN_SUPPORTED_MINOR: u64 = 31;
942
943impl GitCapabilities {
944    /// Whether the binary meets the supported floor (git ≥ 2.31).
945    pub fn is_supported(&self) -> bool {
946        (self.version.major, self.version.minor) >= (MIN_SUPPORTED_MAJOR, MIN_SUPPORTED_MINOR)
947    }
948
949    /// Error unless [`is_supported`](Self::is_supported) — a clear "needs git
950    /// ≥ 2.31, found 2.7.4" instead of a cryptic argv failure later.
951    pub fn ensure_supported(&self) -> Result<()> {
952        if self.is_supported() {
953            return Ok(());
954        }
955        Err(Error::spawn(
956            BINARY,
957            std::io::Error::new(
958                std::io::ErrorKind::Unsupported,
959                format!(
960                    "vcs-git requires git >= {MIN_SUPPORTED_MAJOR}.{MIN_SUPPORTED_MINOR} \
961                     (validated on 2.54), found {}",
962                    self.version
963                ),
964            ),
965        ))
966    }
967}