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}