• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rraval / git-nomad / 11897331888

18 Nov 2024 05:02PM UTC coverage: 99.96% (-0.04%) from 100.0%
11897331888

push

github

web-flow
Support branch names containing forward slashes (#209)

Closes #180.

- Canonicalize tmpdir in git_binary tests.
  - On MacOS hosts, `/tmp` is symlinked to `/private/tmp` causing these paths
    to appear unequal.
- Support branch names containing additional forward slashes.
- Update CHANGELOG.

30 of 30 new or added lines in 1 file covered. (100.0%)

1 existing line in 1 file now uncovered.

2526 of 2527 relevant lines covered (99.96%)

23.84 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.86
/src/git_binary.rs
1
//! See [`GitBinary`] for the primary entry point.
2

3
use anyhow::{bail, Result};
4
use std::{borrow::Cow, collections::HashSet, ffi::OsStr, path::Path, process::Command};
5

6
use crate::{
7
    git_ref::GitRef,
8
    renderer::Renderer,
9
    snapshot::{PruneFrom, Snapshot},
10
    types::{Branch, Host, NomadRef, Remote, User},
11
    verbosity::{is_output_allowed, output_stdout, run_notable, run_trivial, Verbosity},
12
};
13

14
/// Run the git binary inheriting the same environment that this git-nomad
15
/// binary is running under.
16
#[cfg(not(test))]
17
pub fn git_command(name: impl AsRef<OsStr>) -> Command {
18
    Command::new(name)
19
}
20

21
/// Constructs a standalone git invocation that works in test environments without any ambient
22
/// configuration.
23
#[cfg(test)]
24
pub fn git_command(name: impl AsRef<OsStr>) -> Command {
347✔
25
    let mut command = Command::new(name);
347✔
26
    command
347✔
27
        // These allow tests to exercise global config reading behaviour
347✔
28
        .env_remove("GIT_CONFIG_SYSTEM")
347✔
29
        .env_remove("GIT_CONFIG_GLOBAL")
347✔
30
        // This allows `git commit` to work
347✔
31
        .args([
347✔
32
            "-c",
347✔
33
            "user.name=git-nomad",
347✔
34
            "-c",
347✔
35
            "user.email=git-nomad@invalid",
347✔
36
        ]);
347✔
37
    command
347✔
38
}
347✔
39

40
/// Containerizes all the naming schemes used by nomad from the wild west of all other git tools,
41
/// both built-in and third party.
42
mod namespace {
43
    use crate::{
44
        git_ref::GitRef,
45
        types::{Branch, Host, NomadRef, User},
46
    };
47

48
    /// The main name that we declare to be ours and nobody elses. This lays claim to the section
49
    /// in `git config` and the `refs/{PREFIX}` hierarchy in all git repos!
50
    pub const PREFIX: &str = "nomad";
51

52
    /// Where information is stored for `git config`.
53
    pub fn config_key(key: &str) -> String {
48✔
54
        format!("{}.{}", PREFIX, key)
48✔
55
    }
48✔
56

57
    /// The refspec to list remote nomad managed refs.
58
    pub fn list_refspec(user: &User) -> String {
33✔
59
        format!("refs/{prefix}/{user}/*", prefix = PREFIX, user = user.0)
33✔
60
    }
33✔
61

62
    /// The refspec to fetch remote nomad managed refs as local refs.
63
    ///
64
    /// `refs/nomad/rraval/apollo/master` becomes `refs/nomad/apollo/master`.
65
    ///
66
    /// `refs/nomad/rraval/boreas/feature` becomes `refs/nomad/boreas/feature`.
67
    pub fn fetch_refspec(user: &User) -> String {
20✔
68
        format!(
20✔
69
            "+{remote_pattern}:refs/{prefix}/*",
20✔
70
            remote_pattern = list_refspec(user),
20✔
71
            prefix = PREFIX,
20✔
72
        )
20✔
73
    }
20✔
74

75
    /// The refspec to push local branches as nomad managed refs in the remote.
76
    ///
77
    /// When run on host `boreas` that has a branch named `feature`:
78
    /// `refs/heads/feature` becomes `refs/nomad/rraval/boreas/feature`.
79
    pub fn push_refspec(user: &User, host: &Host) -> String {
15✔
80
        format!(
15✔
81
            "+refs/heads/*:refs/{prefix}/{user}/{host}/*",
15✔
82
            prefix = PREFIX,
15✔
83
            user = user.0,
15✔
84
            host = host.0,
15✔
85
        )
15✔
86
    }
15✔
87

88
    impl<Ref> NomadRef<'_, Ref> {
89
        /// A nomad ref in the local clone, which elides the user name for convenience.
90
        #[cfg(test)]
91
        pub fn to_git_local_ref(&self) -> String {
4✔
92
            format!("refs/{}/{}/{}", PREFIX, self.host.0, self.branch.0)
4✔
93
        }
4✔
94

95
        /// A nomad ref in the remote. The remote may have many users that all use `git-nomad` and
96
        /// so shouldn't step on each others toes.
97
        pub fn to_git_remote_ref(&self) -> String {
6✔
98
            format!(
6✔
99
                "refs/{}/{}/{}/{}",
6✔
100
                PREFIX, self.user.0, self.host.0, self.branch.0
6✔
101
            )
6✔
102
        }
6✔
103
    }
104

105
    impl NomadRef<'_, GitRef> {
106
        /// Constructs a [`NomadRef`] from a git ref in the local clone, which elides the user name
107
        /// for convenience.
108
        pub fn from_git_local_ref<'a>(
132✔
109
            user: &'a User,
132✔
110
            git_ref: GitRef,
132✔
111
        ) -> Result<NomadRef<'a, GitRef>, GitRef> {
132✔
112
            let parts = git_ref.name.split('/').collect::<Vec<_>>();
132✔
113
            match parts.as_slice() {
132✔
114
                ["refs", prefix, host, branch_segments @ ..] => {
132✔
115
                    if prefix != &PREFIX {
132✔
116
                        return Err(git_ref);
87✔
117
                    }
45✔
118

45✔
119
                    Ok(NomadRef {
45✔
120
                        user: user.always_borrow(),
45✔
121
                        host: Host::from(host.to_string()),
45✔
122
                        branch: Branch::from(branch_segments.join("/")),
45✔
123
                        ref_: git_ref,
45✔
124
                    })
45✔
125
                }
UNCOV
126
                _ => Err(git_ref),
×
127
            }
128
        }
132✔
129

130
        /// Constructs a [`NomadRef`] from a git ref in the remote, which includes the user as part
131
        /// of the ref name.
132
        pub fn from_git_remote_ref(git_ref: GitRef) -> Result<NomadRef<'static, GitRef>, GitRef> {
49✔
133
            let parts = git_ref.name.split('/').collect::<Vec<_>>();
49✔
134
            match parts.as_slice() {
49✔
135
                ["refs", prefix, user, host, branch_name] => {
38✔
136
                    if prefix != &PREFIX {
38✔
137
                        return Err(git_ref);
1✔
138
                    }
37✔
139

37✔
140
                    Ok(NomadRef {
37✔
141
                        user: User::from(user.to_string()),
37✔
142
                        host: Host::from(host.to_string()),
37✔
143
                        branch: Branch::from(branch_name.to_string()),
37✔
144
                        ref_: git_ref,
37✔
145
                    })
37✔
146
                }
147
                _ => Err(git_ref),
11✔
148
            }
149
        }
49✔
150
    }
151

152
    #[cfg(test)]
153
    mod tests {
154
        use crate::{
155
            git_ref::GitRef,
156
            types::{Branch, Host, NomadRef, User},
157
        };
158

159
        const USER: &str = "user0";
160
        const HOST: &str = "host0";
161
        const BRANCH: &str = "branch0";
162

163
        /// [`NomadRef::from_git_local_ref`] should be able to parse ref names produced by
164
        /// [`NomadRef::to_git_local_ref`] (they are duals).
165
        #[test]
166
        fn test_to_and_from_local_ref() {
1✔
167
            let local_ref_name = NomadRef {
1✔
168
                user: User::from(USER),
1✔
169
                host: Host::from(HOST),
1✔
170
                branch: Branch::from(BRANCH),
1✔
171
                ref_: (),
1✔
172
            }
1✔
173
            .to_git_local_ref();
1✔
174

1✔
175
            let local_git_ref = GitRef {
1✔
176
                commit_id: "some_commit_id".to_string(),
1✔
177
                name: local_ref_name,
1✔
178
            };
1✔
179

1✔
180
            let user = &User::from(USER);
1✔
181
            let nomad_ref = NomadRef::<GitRef>::from_git_local_ref(user, local_git_ref).unwrap();
1✔
182

1✔
183
            assert_eq!(&nomad_ref.user.0, USER);
1✔
184
            assert_eq!(&nomad_ref.host.0, HOST);
1✔
185
            assert_eq!(&nomad_ref.branch.0, BRANCH);
1✔
186
        }
1✔
187

188
        #[test]
189
        fn test_from_local_ref_with_slashes() {
1✔
190
            for segment_count in 1..3 {
3✔
191
                let segments: Vec<_> = std::iter::repeat(BRANCH).take(segment_count).collect();
2✔
192
                let branch = segments.join("/");
2✔
193

2✔
194
                let local_ref_name = NomadRef {
2✔
195
                    user: User::from(USER),
2✔
196
                    host: Host::from(HOST),
2✔
197
                    branch: Branch::from(branch.clone()),
2✔
198
                    ref_: (),
2✔
199
                }
2✔
200
                .to_git_local_ref();
2✔
201

2✔
202
                let local_git_ref = GitRef {
2✔
203
                    commit_id: "some_commit_id".to_string(),
2✔
204
                    name: local_ref_name,
2✔
205
                };
2✔
206

2✔
207
                let user = &User::from(USER);
2✔
208
                let nomad_ref =
2✔
209
                    NomadRef::<GitRef>::from_git_local_ref(user, local_git_ref).unwrap();
2✔
210

2✔
211
                assert_eq!(&nomad_ref.user.0, USER);
2✔
212
                assert_eq!(&nomad_ref.host.0, HOST);
2✔
213
                assert_eq!(nomad_ref.branch.0, std::borrow::Cow::from(branch));
2✔
214
            }
215
        }
1✔
216

217
        /// [`NomadRef::from_git_remote_ref`] should be able to parse ref names produced by
218
        /// [`NomadRef::to_git_local_ref`] (they are duals).
219
        #[test]
220
        fn test_to_and_from_remote_ref() {
1✔
221
            let remote_ref_name = NomadRef {
1✔
222
                user: User::from(USER),
1✔
223
                host: Host::from(HOST),
1✔
224
                branch: Branch::from(BRANCH),
1✔
225
                ref_: (),
1✔
226
            }
1✔
227
            .to_git_remote_ref();
1✔
228

1✔
229
            let remote_git_ref = GitRef {
1✔
230
                commit_id: "some_commit_id".to_string(),
1✔
231
                name: remote_ref_name,
1✔
232
            };
1✔
233

1✔
234
            let nomad_ref = NomadRef::<GitRef>::from_git_remote_ref(remote_git_ref).unwrap();
1✔
235

1✔
236
            assert_eq!(&nomad_ref.user.0, USER);
1✔
237
            assert_eq!(&nomad_ref.host.0, HOST);
1✔
238
            assert_eq!(&nomad_ref.branch.0, BRANCH);
1✔
239
        }
1✔
240

241
        /// [`NomadRef::from_git_remote_ref`] should refuse to parse refs with a different prefix.
242
        #[test]
243
        fn test_from_remote_ref_wrong_prefix() {
1✔
244
            let remote_git_ref = GitRef {
1✔
245
                commit_id: "some_commit_id".to_string(),
1✔
246
                name: "refs/something/user/host/branch".to_string(),
1✔
247
            };
1✔
248

1✔
249
            let parsed = NomadRef::<GitRef>::from_git_remote_ref(remote_git_ref);
1✔
250
            assert!(parsed.is_err());
1✔
251
        }
1✔
252
    }
253
}
254

255
/// Implements repository manipulations by delegating to some ambient `git` binary that exists
256
/// somewhere on the system.
257
#[derive(PartialEq, Eq)]
258
pub struct GitBinary<'name> {
259
    /// Used to actually execute commands while reporting progress to the user.
260
    pub verbosity: Option<Verbosity>,
261

262
    /// The name of the `git` binary to use. Implemented on top of [`Command::new`], so
263
    /// non-absolute paths are looked up against `$PATH`.
264
    name: Cow<'name, str>,
265

266
    /// The absolute path to the `.git` directory of the repository.
267
    git_dir: String,
268
}
269

270
impl<'name> GitBinary<'name> {
271
    /// Create a new [`GitBinary`] by finding the `.git` dir relative to `cwd`, which implements
272
    /// the usual git rules of searching ancestor directories.
273
    pub fn new(
54✔
274
        renderer: &mut impl Renderer,
54✔
275
        verbosity: Option<Verbosity>,
54✔
276
        name: Cow<'name, str>,
54✔
277
        cwd: &Path,
54✔
278
    ) -> Result<Self> {
54✔
279
        let git_dir = run_trivial(
54✔
280
            renderer,
54✔
281
            verbosity,
54✔
282
            "Resolving .git directory",
54✔
283
            git_command(name.as_ref())
54✔
284
                .current_dir(cwd)
54✔
285
                .args(["rev-parse", "--absolute-git-dir"]),
54✔
286
        )
54✔
287
        .and_then(output_stdout)
54✔
288
        .map(LineArity::from)
54✔
289
        .and_then(LineArity::one)?;
54✔
290

291
        Ok(GitBinary {
54✔
292
            verbosity,
54✔
293
            name,
54✔
294
            git_dir,
54✔
295
        })
54✔
296
    }
54✔
297
}
298

299
impl GitBinary<'_> {
300
    /// Invoke a git sub-command with an explicit `--git-dir` to make it independent of the working
301
    /// directory it is invoked from.
302
    pub fn command(&self) -> Command {
179✔
303
        let mut command = git_command(self.name.as_ref());
179✔
304
        command.args(["--git-dir", &self.git_dir]);
179✔
305
        command
179✔
306
    }
179✔
307

308
    /// Wraps `git config` to read a single namespaced value.
309
    pub fn get_config(&self, renderer: &mut impl Renderer, key: &str) -> Result<Option<String>> {
42✔
310
        self.get_config_with_env(renderer, key, [] as [(&str, &str); 0])
42✔
311
    }
42✔
312

313
    fn get_config_with_env(
44✔
314
        &self,
44✔
315
        renderer: &mut impl Renderer,
44✔
316
        key: &str,
44✔
317
        vars: impl IntoIterator<Item = (impl AsRef<OsStr>, impl AsRef<OsStr>)>,
44✔
318
    ) -> Result<Option<String>> {
44✔
319
        run_trivial(
44✔
320
            renderer,
44✔
321
            self.verbosity,
44✔
322
            format!("Get config {}", key),
44✔
323
            self.command().envs(vars).args([
44✔
324
                "config",
44✔
325
                // Use a default to prevent git from returning a non-zero exit code when the value does
44✔
326
                // not exist.
44✔
327
                "--default",
44✔
328
                "",
44✔
329
                "--get",
44✔
330
                &namespace::config_key(key),
44✔
331
            ]),
44✔
332
        )
44✔
333
        .and_then(output_stdout)
44✔
334
        .map(LineArity::from)
44✔
335
        .and_then(LineArity::zero_or_one)
44✔
336
    }
44✔
337

338
    /// Wraps `git config` to write a single namespaced value.
339
    #[cfg(test)]
340
    pub fn set_config(&self, renderer: &mut impl Renderer, key: &str, value: &str) -> Result<()> {
4✔
341
        run_trivial(
4✔
342
            renderer,
4✔
343
            self.verbosity,
4✔
344
            format!("Set config {} = {}", key, value),
4✔
345
            self.command().args([
4✔
346
                "config",
4✔
347
                "--local",
4✔
348
                "--replace-all",
4✔
349
                &namespace::config_key(key),
4✔
350
                value,
4✔
351
            ]),
4✔
352
        )?;
4✔
353
        Ok(())
4✔
354
    }
4✔
355

356
    /// Wraps `git fetch` to fetch refs from a given remote into the local repository.
357
    ///
358
    /// # Panics
359
    ///
360
    /// If `refspecs` is empty, which means git will use the user configured default behaviour
361
    /// which is definitely not what we want.
362
    fn fetch_refspecs<Description, RefSpec>(
20✔
363
        &self,
20✔
364
        renderer: &mut impl Renderer,
20✔
365
        description: Description,
20✔
366
        remote: &Remote,
20✔
367
        refspecs: &[RefSpec],
20✔
368
    ) -> Result<()>
20✔
369
    where
20✔
370
        Description: AsRef<str>,
20✔
371
        RefSpec: AsRef<OsStr>,
20✔
372
    {
20✔
373
        assert!(!refspecs.is_empty());
20✔
374
        run_notable(
20✔
375
            renderer,
20✔
376
            self.verbosity,
20✔
377
            description,
20✔
378
            self.command().args(["fetch", &remote.0]).args(refspecs),
20✔
379
        )?;
20✔
380
        Ok(())
20✔
381
    }
20✔
382

383
    /// Wraps `git push` to push refs from the local repository into the given remote.
384
    ///
385
    /// # Panics
386
    ///
387
    /// If `refspecs` is empty, which means git will use the user configured default behaviour
388
    /// which is definitely not what we want.
389
    fn push_refspecs<Description, RefSpec>(
19✔
390
        &self,
19✔
391
        renderer: &mut impl Renderer,
19✔
392
        description: Description,
19✔
393
        remote: &Remote,
19✔
394
        refspecs: &[RefSpec],
19✔
395
    ) -> Result<()>
19✔
396
    where
19✔
397
        Description: AsRef<str>,
19✔
398
        RefSpec: AsRef<OsStr>,
19✔
399
    {
19✔
400
        assert!(!refspecs.is_empty());
19✔
401
        run_notable(
19✔
402
            renderer,
19✔
403
            self.verbosity,
19✔
404
            description,
19✔
405
            self.command()
19✔
406
                .args(["push", "--no-verify", &remote.0])
19✔
407
                .args(refspecs),
19✔
408
        )?;
19✔
409
        Ok(())
19✔
410
    }
19✔
411

412
    /// Extract a single `GitRef` for a given `ref_name`.
413
    #[cfg(test)]
414
    pub fn get_ref<Description, RefName>(
23✔
415
        &self,
23✔
416
        renderer: &mut impl Renderer,
23✔
417
        description: Description,
23✔
418
        ref_name: RefName,
23✔
419
    ) -> Result<GitRef>
23✔
420
    where
23✔
421
        Description: AsRef<str>,
23✔
422
        RefName: AsRef<str>,
23✔
423
    {
23✔
424
        run_trivial(
23✔
425
            renderer,
23✔
426
            self.verbosity,
23✔
427
            description,
23✔
428
            self.command()
23✔
429
                .args(["show-ref", "--verify", ref_name.as_ref()]),
23✔
430
        )
23✔
431
        .and_then(output_stdout)
23✔
432
        .map(LineArity::from)
23✔
433
        .and_then(LineArity::one)
23✔
434
        .and_then(|line| GitRef::parse_show_ref_line(&line).map_err(Into::into))
23✔
435
    }
23✔
436

437
    /// List all the non-HEAD refs in the repository as `GitRef`s.
438
    pub fn list_refs<Description>(
41✔
439
        &self,
41✔
440
        renderer: &mut impl Renderer,
41✔
441
        description: Description,
41✔
442
    ) -> Result<Vec<GitRef>>
41✔
443
    where
41✔
444
        Description: AsRef<str>,
41✔
445
    {
41✔
446
        let output = run_trivial(
41✔
447
            renderer,
41✔
448
            self.verbosity,
41✔
449
            description,
41✔
450
            self.command().arg("show-ref"),
41✔
451
        )
41✔
452
        .and_then(output_stdout)?;
41✔
453
        output
41✔
454
            .lines()
41✔
455
            .map(|line| GitRef::parse_show_ref_line(line).map_err(Into::into))
153✔
456
            .collect()
41✔
457
    }
41✔
458

459
    /// Wraps `git ls-remote` to query a remote for all refs that match the given `refspecs`.
460
    ///
461
    /// # Panics
462
    ///
463
    /// If `refspecs` is empty, which means git will list all refs, which is never what we want.
464
    fn list_remote_refs<Description, RefSpec>(
13✔
465
        &self,
13✔
466
        renderer: &mut impl Renderer,
13✔
467
        description: Description,
13✔
468
        remote: &Remote,
13✔
469
        refspecs: &[RefSpec],
13✔
470
    ) -> Result<Vec<GitRef>>
13✔
471
    where
13✔
472
        Description: AsRef<str>,
13✔
473
        RefSpec: AsRef<OsStr>,
13✔
474
    {
13✔
475
        assert!(!refspecs.is_empty());
13✔
476
        let output = run_notable(
13✔
477
            renderer,
13✔
478
            self.verbosity,
13✔
479
            description,
13✔
480
            self.command()
13✔
481
                .arg("ls-remote")
13✔
482
                .arg(remote.0.as_ref())
13✔
483
                .args(refspecs),
13✔
484
        )
13✔
485
        .and_then(output_stdout)?;
13✔
486
        output
13✔
487
            .lines()
13✔
488
            .map(|line| GitRef::parse_ls_remote_line(line).map_err(Into::into))
23✔
489
            .collect()
13✔
490
    }
13✔
491

492
    /// Delete a ref from the repository.
493
    ///
494
    /// Note that deleting refs on a remote is done via [`GitBinary::push_refspecs`].
495
    fn delete_ref<Description>(
6✔
496
        &self,
6✔
497
        renderer: &mut impl Renderer,
6✔
498
        description: Description,
6✔
499
        git_ref: &GitRef,
6✔
500
    ) -> Result<()>
6✔
501
    where
6✔
502
        Description: AsRef<str>,
6✔
503
    {
6✔
504
        let mut command = self.command();
6✔
505
        command.args(["update-ref", "-d", &git_ref.name, &git_ref.commit_id]);
6✔
506
        run_notable(renderer, self.verbosity, description, &mut command)?;
6✔
507
        Ok(())
6✔
508
    }
6✔
509

510
    /// Get the current branch, which may fail if the work tree is in a detached HEAD state.
511
    pub fn current_branch(&self, renderer: &mut impl Renderer) -> Result<Branch<'static>> {
4✔
512
        let mut command = self.command();
4✔
513
        command.args(["symbolic-ref", "--short", "HEAD"]);
4✔
514
        run_trivial(
4✔
515
            renderer,
4✔
516
            self.verbosity,
4✔
517
            "Reading current branch",
4✔
518
            &mut command,
4✔
519
        )
4✔
520
        .and_then(output_stdout)
4✔
521
        .map(LineArity::from)
4✔
522
        .and_then(LineArity::one)
4✔
523
        .map(Branch::from)
4✔
524
    }
4✔
525

526
    /// Create a git branch named `branch_name`.
527
    #[cfg(test)]
528
    pub fn create_branch(
1✔
529
        &self,
1✔
530
        renderer: &mut impl Renderer,
1✔
531
        description: impl AsRef<str>,
1✔
532
        branch_name: &Branch,
1✔
533
    ) -> Result<()> {
1✔
534
        let mut command = self.command();
1✔
535
        command.args(["branch", &branch_name.0]);
1✔
536
        run_notable(renderer, self.verbosity, description, &mut command)?;
1✔
537
        Ok(())
1✔
538
    }
1✔
539

540
    /// Delete a git branch named `branch_name`.
541
    #[cfg(test)]
542
    pub fn delete_branch(
1✔
543
        &self,
1✔
544
        renderer: &mut impl Renderer,
1✔
545
        description: impl AsRef<str>,
1✔
546
        branch_name: &Branch,
1✔
547
    ) -> Result<()> {
1✔
548
        let mut command = self.command();
1✔
549
        command.args(["branch", "-d", &branch_name.0]);
1✔
550
        run_notable(renderer, self.verbosity, description, &mut command)?;
1✔
551
        Ok(())
1✔
552
    }
1✔
553

554
    /// Should higher level commands be producing output, or has the user requested quiet mode?
555
    pub fn is_output_allowed(&self) -> bool {
12✔
556
        is_output_allowed(self.verbosity)
12✔
557
    }
12✔
558

559
    /// Build a point in time snapshot for all refs that nomad cares about from the state in the
560
    /// local git clone.
561
    pub fn snapshot<'a>(
22✔
562
        &self,
22✔
563
        renderer: &mut impl Renderer,
22✔
564
        user: &'a User,
22✔
565
    ) -> Result<Snapshot<'a, GitRef>> {
22✔
566
        let refs = self.list_refs(renderer, "Fetching all refs")?;
22✔
567

568
        let mut local_branches = HashSet::<Branch>::new();
22✔
569
        let mut nomad_refs = Vec::<NomadRef<'a, GitRef>>::new();
22✔
570

571
        for r in refs {
120✔
572
            if let Some(name) = r.name.strip_prefix("refs/heads/") {
98✔
573
                local_branches.insert(Branch::from(name.to_string()));
23✔
574
            }
75✔
575

576
            if let Ok(nomad_ref) = NomadRef::<GitRef>::from_git_local_ref(user, r) {
98✔
577
                nomad_refs.push(nomad_ref);
35✔
578
            }
63✔
579
        }
580

581
        Ok(Snapshot::new(user, local_branches, nomad_refs))
22✔
582
    }
22✔
583

584
    /// Fetch all nomad managed refs from a given remote.
585
    pub fn fetch_nomad_refs(
20✔
586
        &self,
20✔
587
        renderer: &mut impl Renderer,
20✔
588
        user: &User,
20✔
589
        remote: &Remote,
20✔
590
    ) -> Result<()> {
20✔
591
        self.fetch_refspecs(
20✔
592
            renderer,
20✔
593
            format!("Fetching branches from {}", remote.0),
20✔
594
            remote,
20✔
595
            &[&namespace::fetch_refspec(user)],
20✔
596
        )
20✔
597
    }
20✔
598

599
    /// List all nomad managed refs from a given remote.
600
    ///
601
    /// Separated from [`Self::fetch_nomad_refs`] because not all callers want to pay the overhead
602
    /// of actually listing the fetched refs.
603
    pub fn list_nomad_refs(
13✔
604
        &self,
13✔
605
        renderer: &mut impl Renderer,
13✔
606
        user: &User,
13✔
607
        remote: &Remote,
13✔
608
    ) -> Result<impl Iterator<Item = NomadRef<GitRef>>> {
13✔
609
        // In an ideal world, we would be able to get the list of refs fetched directly from `git`.
610
        //
611
        // However, `git fetch` is a porcelain command and we don't want to get into parsing its
612
        // output, so do an entirely separate network fetch with the plumbing `git ls-remote` which
613
        // we can parse instead.
614
        let remote_refs = self.list_remote_refs(
13✔
615
            renderer,
13✔
616
            format!("Listing branches at {}", remote.0),
13✔
617
            remote,
13✔
618
            &[&namespace::list_refspec(user)],
13✔
619
        )?;
13✔
620

621
        Ok(remote_refs
13✔
622
            .into_iter()
13✔
623
            .filter_map(|ref_| NomadRef::<GitRef>::from_git_remote_ref(ref_).ok()))
23✔
624
    }
13✔
625

626
    /// Push local branches to nomad managed refs in the remote.
627
    pub fn push_nomad_refs(
15✔
628
        &self,
15✔
629
        renderer: &mut impl Renderer,
15✔
630
        user: &User,
15✔
631
        host: &Host,
15✔
632
        remote: &Remote,
15✔
633
    ) -> Result<()> {
15✔
634
        self.push_refspecs(
15✔
635
            renderer,
15✔
636
            format!("Pushing local branches to {}", remote.0),
15✔
637
            remote,
15✔
638
            &[&namespace::push_refspec(user, host)],
15✔
639
        )
15✔
640
    }
15✔
641

642
    /// Delete the given nomad managed refs.
643
    pub fn prune_nomad_refs<'a>(
15✔
644
        &self,
15✔
645
        renderer: &mut impl Renderer,
15✔
646
        remote: &Remote,
15✔
647
        prune: impl Iterator<Item = PruneFrom<'a, GitRef>>,
15✔
648
    ) -> Result<()> {
15✔
649
        let mut refspecs = Vec::<String>::new();
15✔
650
        let mut refs = Vec::<GitRef>::new();
15✔
651

652
        for prune_from in prune {
21✔
653
            if let PruneFrom::LocalAndRemote(ref nomad_ref) = prune_from {
6✔
654
                refspecs.push(format!(":{}", nomad_ref.to_git_remote_ref()));
5✔
655
            }
5✔
656

657
            refs.push(
6✔
658
                match prune_from {
6✔
659
                    PruneFrom::LocalOnly(nomad_ref) | PruneFrom::LocalAndRemote(nomad_ref) => {
5✔
660
                        nomad_ref
6✔
661
                    }
662
                }
663
                .ref_,
664
            );
665
        }
666

667
        // Delete from the remote first
668
        if !refspecs.is_empty() {
15✔
669
            self.push_refspecs(
4✔
670
                renderer,
4✔
671
                format!("Pruning branches at {}", remote.0),
4✔
672
                remote,
4✔
673
                &refspecs,
4✔
674
            )?;
4✔
675
        }
11✔
676

677
        // ... then delete locally. This order means that interruptions leave the local ref around
678
        // to be picked up and pruned again.
679
        //
680
        // In practice, we do a fetch from the remote first anyways, which would recreate the local
681
        // ref if this code deleted local refs first and then was interrupted.
682
        //
683
        // But that is non-local reasoning and this ordering is theoretically correct.
684
        for r in refs {
21✔
685
            self.delete_ref(
6✔
686
                renderer,
6✔
687
                format!("  Delete {} (was {})", r.name, r.commit_id),
6✔
688
                &r,
6✔
689
            )?;
6✔
690
        }
691

692
        Ok(())
15✔
693
    }
15✔
694
}
695

696
/// Utility to parse line based output of various `git` sub-commands.
697
#[derive(Debug)]
698
pub enum LineArity {
699
    /// The command produced no lines.
700
    Zero(),
701
    /// The command produced exactly one line.
702
    One(String),
703
    /// The command produced two or more lines.
704
    Many(String),
705
}
706

707
impl From<String> for LineArity {
708
    /// Parse a [`LineArity`] from an arbitrary line.
709
    ///
710
    /// Coerces the empty line as [`LineArity::Zero`].
711
    fn from(string: String) -> Self {
135✔
712
        let mut lines = string.lines().take(2).collect::<Vec<_>>();
135✔
713
        let last = lines.pop();
135✔
714

135✔
715
        match last {
135✔
716
            None => LineArity::Zero(),
2✔
717
            Some(last) => {
133✔
718
                if lines.is_empty() {
133✔
719
                    if last.is_empty() {
131✔
720
                        LineArity::Zero()
40✔
721
                    } else {
722
                        LineArity::One(last.to_owned())
91✔
723
                    }
724
                } else {
725
                    LineArity::Many(string)
2✔
726
                }
727
            }
728
        }
729
    }
135✔
730
}
731

732
impl LineArity {
733
    /// The caller expects the output to only have a single line.
734
    pub fn one(self) -> Result<String> {
86✔
735
        if let LineArity::One(line) = self {
86✔
736
            Ok(line)
83✔
737
        } else {
738
            bail!("Expected one line, got {:?}", self);
3✔
739
        }
740
    }
86✔
741

742
    /// The caller expects the output to have zero or one line.
743
    pub fn zero_or_one(self) -> Result<Option<String>> {
49✔
744
        match self {
49✔
745
            LineArity::Zero() => Ok(None),
40✔
746
            LineArity::One(line) => Ok(Some(line)),
8✔
747
            LineArity::Many(string) => bail!("Expected 0 or 1 line, got {:?}", string),
1✔
748
        }
749
    }
49✔
750
}
751

752
#[cfg(test)]
753
mod test_line_arity {
754
    use super::LineArity;
755

756
    /// No lines counts as zero.
757
    #[test]
758
    fn test_empty() {
1✔
759
        let arity = || LineArity::from("".to_string());
2✔
760
        assert!(arity().one().is_err());
1✔
761
        assert_eq!(arity().zero_or_one().unwrap(), None);
1✔
762
    }
1✔
763

764
    /// An empty line counts as zero.
765
    #[test]
766
    fn test_newline() {
1✔
767
        let arity = || LineArity::from("\n".to_string());
2✔
768
        assert!(arity().one().is_err());
1✔
769
        assert_eq!(arity().zero_or_one().unwrap(), None);
1✔
770
    }
1✔
771

772
    /// A line without a trailing newline counts as one.
773
    #[test]
774
    fn test_one_line_without_newline() {
1✔
775
        let arity = || LineArity::from("line".to_string());
2✔
776
        assert_eq!(arity().one().unwrap(), "line".to_string());
1✔
777
        assert_eq!(arity().zero_or_one().unwrap(), Some("line".to_string()));
1✔
778
    }
1✔
779

780
    /// A line with a trailing newline counts as one.
781
    #[test]
782
    fn test_one_line_with_newline() {
1✔
783
        let arity = || LineArity::from("line\n".to_string());
2✔
784
        assert_eq!(arity().one().unwrap(), "line".to_string());
1✔
785
        assert_eq!(arity().zero_or_one().unwrap(), Some("line".to_string()));
1✔
786
    }
1✔
787

788
    /// Two lines with newlines count as many.
789
    #[test]
790
    fn test_two_lines() {
1✔
791
        let arity = || LineArity::from("line\nanother\n".to_string());
2✔
792
        assert!(arity().one().is_err());
1✔
793
        assert!(arity().zero_or_one().is_err());
1✔
794
    }
1✔
795
}
796

797
#[cfg(test)]
798
mod test_impl {
799
    use std::{borrow::Cow, fs};
800

801
    use tempfile::{tempdir, TempDir};
802

803
    use crate::{
804
        renderer::test::NoRenderer,
805
        types::Branch,
806
        verbosity::{run_notable, Verbosity},
807
    };
808

809
    use super::{git_command, GitBinary};
810
    use anyhow::Result;
811

812
    const INITIAL_BRANCH: &str = "branch0";
813

814
    /// Initializes a git repository in a temporary directory.
815
    fn git_init() -> Result<(Cow<'static, str>, TempDir)> {
8✔
816
        let name = "git".to_owned();
8✔
817
        let tmpdir = tempdir()?;
8✔
818

819
        run_notable(
8✔
820
            &mut NoRenderer,
8✔
821
            Some(Verbosity::max()),
8✔
822
            "",
8✔
823
            git_command(&name).current_dir(tmpdir.path()).args([
8✔
824
                "init",
8✔
825
                "--initial-branch",
8✔
826
                INITIAL_BRANCH,
8✔
827
            ]),
8✔
828
        )?;
8✔
829

830
        Ok((Cow::Owned(name), tmpdir))
8✔
831
    }
8✔
832

833
    /// Find the `.git` directory when run from the root of the repo.
834
    #[test]
835
    fn toplevel_at_root() -> Result<()> {
1✔
836
        let (name, tmpdir) = git_init()?;
1✔
837

838
        let git = GitBinary::new(&mut NoRenderer, None, name, tmpdir.path())?;
1✔
839
        assert_eq!(
1✔
840
            Some(git.git_dir.as_str()),
1✔
841
            tmpdir.path().join(".git").canonicalize()?.to_str()
1✔
842
        );
843

844
        Ok(())
1✔
845
    }
1✔
846

847
    /// Find the `.git` directory when run from a subdirectory of the repo.
848
    #[test]
849
    fn toplevel_in_subdir() -> Result<()> {
1✔
850
        let (name, tmpdir) = git_init()?;
1✔
851
        let subdir = tmpdir.path().join("subdir");
1✔
852
        fs::create_dir(&subdir)?;
1✔
853

854
        let git = GitBinary::new(&mut NoRenderer, None, name, subdir.as_path())?;
1✔
855
        assert_eq!(
1✔
856
            Some(git.git_dir.as_str()),
1✔
857
            tmpdir.path().join(".git").canonicalize()?.to_str(),
1✔
858
        );
859

860
        Ok(())
1✔
861
    }
1✔
862

863
    /// `get_config` should handle missing configuration.
864
    #[test]
865
    fn read_empty_config() -> Result<()> {
1✔
866
        let (name, tmpdir) = git_init()?;
1✔
867
        let git = GitBinary::new(&mut NoRenderer, None, name, tmpdir.path())?;
1✔
868

869
        let got = git.get_config(&mut NoRenderer, "test.key")?;
1✔
870
        assert_eq!(got, None);
1✔
871

872
        Ok(())
1✔
873
    }
1✔
874

875
    /// Verify read-your-writes.
876
    #[test]
877
    fn write_then_read_config() -> Result<()> {
1✔
878
        let (name, tmpdir) = git_init()?;
1✔
879
        let git = GitBinary::new(&mut NoRenderer, None, name, tmpdir.path())?;
1✔
880

881
        git.set_config(&mut NoRenderer, "key", "testvalue")?;
1✔
882
        let got = git.get_config(&mut NoRenderer, "key")?;
1✔
883

884
        assert_eq!(got, Some("testvalue".to_string()));
1✔
885

886
        Ok(())
1✔
887
    }
1✔
888

889
    /// Generates git config files for testing.
890
    mod gitconfig {
891
        use std::{fs, path::Path};
892

893
        use anyhow::Result;
894
        use tempfile::{tempdir, TempDir};
895

896
        use crate::git_binary::namespace;
897

898
        pub const KEY: &str = "testkey";
899
        pub const VALUE: &str = "testvalue";
900

901
        pub fn write(
2✔
902
            dirs: impl IntoIterator<Item = impl AsRef<Path>>,
2✔
903
            filename: impl AsRef<Path>,
2✔
904
        ) -> Result<TempDir> {
2✔
905
            let root = tempdir()?;
2✔
906

907
            let mut path = root.path().to_path_buf();
2✔
908
            path.extend(dirs);
2✔
909

2✔
910
            fs::create_dir_all(&path)?;
2✔
911

912
            path.push(filename);
2✔
913
            fs::write(
2✔
914
                &path,
2✔
915
                format!("[{}]\n    {} = {}", namespace::PREFIX, KEY, VALUE),
2✔
916
            )?;
2✔
917

918
            Ok(root)
2✔
919
        }
2✔
920
    }
921

922
    /// Git invocations should read from `$HOME/.gitconfig`
923
    #[test]
924
    fn read_home_config() -> Result<()> {
1✔
925
        let (name, tmpdir) = git_init()?;
1✔
926
        let git = GitBinary::new(&mut NoRenderer, None, name, tmpdir.path())?;
1✔
927

928
        let home = gitconfig::write([] as [&str; 0], ".gitconfig")?;
1✔
929
        let got =
1✔
930
            git.get_config_with_env(&mut NoRenderer, gitconfig::KEY, [("HOME", home.path())])?;
1✔
931

932
        assert_eq!(got, Some(gitconfig::VALUE.into()));
1✔
933

934
        Ok(())
1✔
935
    }
1✔
936

937
    /// Git invocations should read from `$XDG_CONFIG_HOME/git/config`
938
    #[test]
939
    fn read_xdg_config() -> Result<()> {
1✔
940
        let (name, tmpdir) = git_init()?;
1✔
941
        let git = GitBinary::new(&mut NoRenderer, None, name, tmpdir.path())?;
1✔
942

943
        let xdg = gitconfig::write(["git"], "config")?;
1✔
944
        let got = git.get_config_with_env(
1✔
945
            &mut NoRenderer,
1✔
946
            gitconfig::KEY,
1✔
947
            [("XDG_CONFIG_HOME", xdg.path())],
1✔
948
        )?;
1✔
949

950
        assert_eq!(got, Some(gitconfig::VALUE.into()));
1✔
951

952
        Ok(())
1✔
953
    }
1✔
954

955
    /// Reading the current branch should work as expected, even when the repository is completely
956
    /// empty (and hence that branch doesn't have a corresponding commit ID).
957
    #[test]
958
    fn current_branch() -> Result<()> {
1✔
959
        let (name, tmpdir) = git_init()?;
1✔
960
        let git = GitBinary::new(&mut NoRenderer, None, name, tmpdir.path())?;
1✔
961

962
        let branch = git.current_branch(&mut NoRenderer)?;
1✔
963
        assert_eq!(branch, Branch::from(INITIAL_BRANCH));
1✔
964

965
        Ok(())
1✔
966
    }
1✔
967

968
    /// Reading the current branch in a detached HEAD state should be handled as an error.
969
    #[test]
970
    fn current_branch_in_detached_head() -> Result<()> {
1✔
971
        let verbosity = Some(Verbosity::max());
1✔
972

973
        let (name, tmpdir) = git_init()?;
1✔
974
        let git = GitBinary::new(&mut NoRenderer, verbosity, name, tmpdir.path())?;
1✔
975

976
        run_notable(
1✔
977
            &mut NoRenderer,
1✔
978
            verbosity,
1✔
979
            "Create an initial commit",
1✔
980
            git.command()
1✔
981
                .args(["commit", "--allow-empty", "-m", "initial commit"]),
1✔
982
        )?;
1✔
983

984
        let head = git.get_ref(&mut NoRenderer, "Get commit ID for HEAD", "HEAD")?;
1✔
985
        run_notable(
1✔
986
            &mut NoRenderer,
1✔
987
            verbosity,
1✔
988
            "Switch to detached HEAD state",
1✔
989
            git.command().args(["checkout", &head.commit_id]),
1✔
990
        )?;
1✔
991

992
        let branch_result = git.current_branch(&mut NoRenderer);
1✔
993
        assert!(branch_result.is_err());
1✔
994

995
        Ok(())
1✔
996
    }
1✔
997
}
998

999
#[cfg(test)]
1000
mod test_backend {
1001
    use crate::{
1002
        git_testing::{GitCommitId, GitRemote, INITIAL_BRANCH},
1003
        verbosity::Verbosity,
1004
    };
1005
    use std::{collections::HashSet, iter::FromIterator};
1006

1007
    use crate::types::NomadRef;
1008

1009
    /// Push should put local branches to remote `refs/nomad/{user}/{host}/{branch}`
1010
    #[test]
1011
    fn push() {
1✔
1012
        let origin = GitRemote::init(Some(Verbosity::max()));
1✔
1013
        let host0 = origin.clone("user0", "host0");
1✔
1014
        host0.push();
1✔
1015

1✔
1016
        assert_eq!(
1✔
1017
            origin.nomad_refs(),
1✔
1018
            HashSet::from_iter([host0.get_nomad_ref(INITIAL_BRANCH).unwrap()]),
1✔
1019
        );
1✔
1020
    }
1✔
1021

1022
    /// Fetch should pull refs for all hosts that have pushed under the configured user under
1023
    /// `refs/nomad/{host}/{branch}`
1024
    #[test]
1025
    fn fetch() {
1✔
1026
        let origin = GitRemote::init(None);
1✔
1027

1✔
1028
        let host0 = origin.clone("user0", "host0");
1✔
1029
        host0.push();
1✔
1030

1✔
1031
        let host1 = origin.clone("user0", "host1");
1✔
1032

1✔
1033
        // Before fetch, the host1 clone should have no nomad refs
1✔
1034
        assert_eq!(host1.nomad_refs(), HashSet::new());
1✔
1035

1036
        // After fetch, we should see the one host0 branch
1037
        host1.fetch();
1✔
1038
        let nomad_refs = host1
1✔
1039
            .list()
1✔
1040
            .map(Into::into)
1✔
1041
            .collect::<HashSet<NomadRef<GitCommitId>>>();
1✔
1042
        assert_eq!(
1✔
1043
            nomad_refs,
1✔
1044
            HashSet::from_iter([host0.get_nomad_ref(INITIAL_BRANCH).unwrap()])
1✔
1045
        );
1✔
1046
    }
1✔
1047

1048
    /// Pushing should create nomad refs in the remote.
1049
    /// Fetching should create nomad refs locally.
1050
    /// Pruning should delete refs in the local and remote.
1051
    #[test]
1052
    fn push_fetch_prune() {
1✔
1053
        let origin = GitRemote::init(Some(Verbosity::max()));
1✔
1054
        let host0 = origin.clone("user0", "host0");
1✔
1055

1✔
1056
        // In the beginning, there are no nomad refs
1✔
1057
        assert_eq!(origin.nomad_refs(), HashSet::new());
1✔
1058
        assert_eq!(host0.nomad_refs(), HashSet::new());
1✔
1059

1060
        // Pushing creates a remote nomad ref, but local remains empty
1061
        host0.push();
1✔
1062
        assert_eq!(
1✔
1063
            origin.nomad_refs(),
1✔
1064
            HashSet::from_iter([host0.get_nomad_ref(INITIAL_BRANCH).unwrap()]),
1✔
1065
        );
1✔
1066
        assert_eq!(host0.nomad_refs(), HashSet::new());
1✔
1067

1068
        // Fetching creates a local nomad ref
1069
        host0.fetch();
1✔
1070
        assert_eq!(
1✔
1071
            origin.nomad_refs(),
1✔
1072
            HashSet::from_iter([host0.get_nomad_ref(INITIAL_BRANCH).unwrap()]),
1✔
1073
        );
1✔
1074
        assert_eq!(
1✔
1075
            host0.nomad_refs(),
1✔
1076
            HashSet::from_iter([host0.get_nomad_ref(INITIAL_BRANCH).unwrap()]),
1✔
1077
        );
1✔
1078

1079
        // Pruning removes the ref remotely and locally
1080
        host0.prune_local_and_remote([INITIAL_BRANCH]);
1✔
1081
        assert_eq!(origin.nomad_refs(), HashSet::new());
1✔
1082
        assert_eq!(host0.nomad_refs(), HashSet::new());
1✔
1083
    }
1✔
1084
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc