girt 0.18.0

An incremental, idiomatic Rust implementation of Git
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
//! Repository hooks: programs a repository asks Git to run at set points.
//!
//! A hook is an executable file named after its event, such as `pre-commit`, `commit-msg`,
//! `post-checkout` or `pre-push`. Git looks for hooks in the directory named by
//! `core.hooksPath` (a relative path is resolved against the top of the working tree, or the Git
//! directory of a bare repository) and otherwise in the `hooks` directory of the common Git
//! directory, which linked worktrees share. A file that exists but is not executable is ignored;
//! Git prints a hint about it.
//!
//! # Running hooks is a trust decision
//!
//! A hook is a program chosen by whoever controls the repository's files or configuration.
//! Running one executes that program with the caller's privileges and environment, so girt never
//! runs hooks unless the caller asks with [`Policy::Run`]. The default, [`Policy::Ignore`], skips
//! hooks as girt has always done. [`Policy::Refuse`] fails with [`Error::Refused`] when a hook
//! would run, for callers that must not silently differ from Git.
//!
//! # Run a hook
//!
//! [`Hooks::new`] locates the hook directory; [`Hooks::run`] runs one hook with the arguments,
//! standard input and extra environment of an [`Invocation`]. Hooks run in the top of the working
//! tree (the Git directory when bare) with exactly the variables of the supplied
//! [`Environment`], plus `GIT_PREFIX` and the invocation's additions. Standard output and standard
//! error are captured together, up to [`Hooks::with_output_limit`], and returned in [`Ran`].
//!
//! ```
//! # #[cfg(unix)] {
//! use std::os::unix::fs::PermissionsExt as _;
//!
//! use girt::hooks::{Hooks, Invocation, Outcome, Policy};
//! use girt::transport::TransportControl;
//! use girt::workflow::Environment;
//! use girt::{InitKind, ObjectFormat, Repository};
//!
//! let directory = tempfile::tempdir()?;
//! let repo = Repository::init(ObjectFormat::Sha1, directory.path(), InitKind::Worktree)?;
//! let hook = repo.common_dir().join("hooks/commit-msg");
//! std::fs::create_dir_all(hook.parent().unwrap())?;
//! std::fs::write(&hook, "#!/bin/sh\necho \"checking $1\"\nexit 1\n")?;
//! std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755))?;
//!
//! let environment: Environment = [("PATH", "/usr/bin:/bin")].into_iter().collect();
//! let hooks = Hooks::new(&repo, &environment)?;
//! let cancel = std::sync::atomic::AtomicBool::new(false);
//! let invocation = Invocation::new().with_argument("MSG");
//!
//! // Ignored unless the caller opts in.
//! let skipped = hooks.run(
//!     "commit-msg",
//!     &invocation,
//!     Policy::Ignore,
//!     TransportControl::new(&cancel),
//! )?;
//! assert!(matches!(skipped, Outcome::Ignored(_)));
//!
//! let Outcome::Ran(ran) = hooks.run(
//!     "commit-msg",
//!     &invocation,
//!     Policy::Run,
//!     TransportControl::new(&cancel),
//! )?
//! else {
//!     panic!("hook did not run");
//! };
//! assert!(!ran.success());
//! assert_eq!(ran.code(), Some(1));
//! assert_eq!(ran.output(), b"checking MSG\n");
//! # }
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
//!
//! # Platforms
//!
//! Hooks run on Linux and macOS. Elsewhere a hook that would run fails with
//! [`Error::Unsupported`]; [`Policy::Ignore`] still skips it.

use std::ffi::OsString;
use std::path::{Path, PathBuf};

use crate::Repository;
use crate::transport::TransportControl;
use crate::workflow::Environment;

#[cfg(any(target_os = "macos", target_os = "linux"))]
pub(crate) mod process;

/// Default bound on captured hook output: 1 MiB.
const DEFAULT_OUTPUT_LIMIT: usize = 1 << 20;

/// Whether an operation runs the repository's hooks.
///
/// Library operations take a policy and default to [`Policy::Ignore`].
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[non_exhaustive]
pub enum Policy {
    /// Skip hooks without looking for them. This is the default.
    #[default]
    Ignore,
    /// Fail with [`Error::Refused`] when an executable hook exists, instead of running it or
    /// differing from Git silently.
    Refuse,
    /// Run hooks as Git does.
    Run,
}

/// The hook directory of one repository, and the environment hooks run with.
#[derive(Clone, Debug)]
pub struct Hooks {
    directory: PathBuf,
    working_directory: PathBuf,
    environment: Environment,
    prefix: OsString,
    output_limit: usize,
}

impl Hooks {
    /// Locates the hook directory of `repository`; hooks will run with `environment`.
    ///
    /// Uses `core.hooksPath` when set, resolving a relative value against the top of the working
    /// tree (the Git directory when bare) and a leading `~/` against `HOME` from `environment`,
    /// and otherwise `<common Git directory>/hooks`. Nothing is read from the directory until a
    /// hook is looked up. `GIT_PREFIX` is the environment's current directory relative to the top
    /// of the working tree, ending in `/`, or empty.
    ///
    /// # Errors
    ///
    /// Returns [`Error::HooksPath`] when `core.hooksPath` uses `~/` without `HOME` in
    /// `environment`, or `~user/`, which girt does not expand. Git would find hooks there, so
    /// skipping them silently could bypass a hook the repository relies on.
    pub fn new(repository: &Repository, environment: &Environment) -> Result<Self, Error> {
        let working_directory = repository
            .worktree()
            .unwrap_or(repository.git_dir())
            .to_owned();
        let directory = match repository.config().string("core", None, "hookspath") {
            Some(path) => {
                if let Some(rest) = path.strip_prefix(b"~/") {
                    let home = environment
                        .get("HOME")
                        .ok_or(Error::HooksPath("~/ needs HOME in the environment"))?;
                    Path::new(home).join(path_from_bytes(rest))
                } else if path.starts_with(b"~") {
                    return Err(Error::HooksPath("~user/ expansion is not supported"));
                } else {
                    working_directory.join(path_from_bytes(path))
                }
            }
            None => repository.common_dir().join("hooks"),
        };
        let prefix = environment
            .current_dir()
            .and_then(|current| current.strip_prefix(repository.worktree()?).ok())
            .filter(|relative| !relative.as_os_str().is_empty())
            .map(|relative| {
                let mut prefix = relative.as_os_str().to_owned();
                prefix.push("/");
                prefix
            })
            .unwrap_or_default();
        Ok(Self {
            directory,
            working_directory,
            environment: environment.clone(),
            prefix,
            output_limit: DEFAULT_OUTPUT_LIMIT,
        })
    }

    /// Bounds captured standard output and error per hook; output beyond it is discarded while
    /// the hook keeps running. The default is 1 MiB.
    #[must_use]
    pub fn with_output_limit(mut self, bytes: usize) -> Self {
        self.output_limit = bytes;
        self
    }

    /// Returns the directory hooks are looked up in.
    pub fn directory(&self) -> &Path {
        &self.directory
    }

    /// Returns the directory hooks run in.
    pub fn working_directory(&self) -> &Path {
        &self.working_directory
    }

    /// Looks up the hook `name` without running it.
    pub fn find(&self, name: &str) -> Found {
        let path = self.directory.join(name);
        match path.metadata() {
            Ok(metadata) if metadata.is_file() => {
                if is_executable(&metadata) {
                    Found::Executable(path)
                } else {
                    Found::NotExecutable(path)
                }
            }
            _ => Found::Absent,
        }
    }

    /// Runs the hook `name` under `policy`.
    ///
    /// Waits for the hook and any process that keeps its output pipes open. `control` can cancel
    /// the wait or set a deadline; the hook's process group is then killed.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Refused`] under [`Policy::Refuse`] when the hook is executable,
    /// [`Error::Unsupported`] on platforms without hook execution, and [`Error::Process`] when
    /// the hook cannot be started, its pipes fail, or `control` cancels it. A hook that exits
    /// non-zero is not an error; inspect [`Ran::success`].
    pub fn run(
        &self,
        name: &str,
        invocation: &Invocation,
        policy: Policy,
        control: TransportControl<'_>,
    ) -> Result<Outcome, Error> {
        if policy == Policy::Ignore {
            return Ok(Outcome::Ignored(name.to_owned()));
        }
        let path = match self.find(name) {
            Found::Absent => return Ok(Outcome::Absent),
            Found::NotExecutable(path) => return Ok(Outcome::NotExecutable(path)),
            Found::Executable(path) if policy == Policy::Refuse => {
                return Err(Error::Refused {
                    hook: name.to_owned(),
                    path,
                });
            }
            Found::Executable(path) => path,
        };
        let mut command = std::process::Command::new(&path);
        command
            .args(&invocation.arguments)
            .current_dir(&self.working_directory)
            .env_clear()
            .envs(self.environment.vars())
            .env("GIT_PREFIX", &self.prefix)
            .envs(
                invocation
                    .environment
                    .iter()
                    .map(|(key, value)| (key, value)),
            );
        #[cfg(any(target_os = "macos", target_os = "linux"))]
        {
            let (status, output) =
                process::run(&mut command, &invocation.stdin, control, self.output_limit).map_err(
                    |source| Error::Process {
                        hook: name.to_owned(),
                        source,
                    },
                )?;
            Ok(Outcome::Ran(Ran { status, output }))
        }
        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
        {
            let _ = (command, control, self.output_limit);
            Err(Error::Unsupported(name.to_owned()))
        }
    }
}

impl Hooks {
    /// Runs a hook whose failure stops the operation, such as `pre-commit` or `pre-push`.
    ///
    /// Like [`Self::run`], but a hook that ran and exited unsuccessfully is an error.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Rejected`] with the hook's exit code and output when it fails, and the
    /// errors of [`Self::run`].
    pub fn run_blocking(
        &self,
        name: &str,
        invocation: &Invocation,
        policy: Policy,
        control: TransportControl<'_>,
    ) -> Result<Outcome, Error> {
        match self.run(name, invocation, policy, control)? {
            Outcome::Ran(ran) if !ran.success() => Err(Error::Rejected {
                hook: name.to_owned(),
                code: ran.code(),
                output: ran.output,
            }),
            outcome => Ok(outcome),
        }
    }

    /// Returns `path` as Git passes repository paths to hooks: relative to the directory hooks
    /// run in when it lies inside it, absolute otherwise.
    pub(crate) fn argument_path(&self, path: &Path) -> OsString {
        path.strip_prefix(&self.working_directory)
            .unwrap_or(path)
            .as_os_str()
            .to_owned()
    }
}

/// What [`Hooks::find`] found.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Found {
    /// No file has the hook's name.
    Absent,
    /// A file exists but is not executable; Git ignores it with a hint.
    NotExecutable(PathBuf),
    /// An executable file that would run.
    Executable(PathBuf),
}

/// The arguments, standard input and extra environment variables of one hook run.
#[derive(Clone, Debug, Default)]
pub struct Invocation {
    arguments: Vec<OsString>,
    stdin: Vec<u8>,
    environment: Vec<(OsString, OsString)>,
}

impl Invocation {
    /// Returns an invocation with no arguments, empty input and no extra variables.
    pub fn new() -> Self {
        Self::default()
    }

    /// Appends an argument.
    #[must_use]
    pub fn with_argument(mut self, argument: impl Into<OsString>) -> Self {
        self.arguments.push(argument.into());
        self
    }

    /// Sets the bytes written to the hook's standard input, which is then closed. A hook that
    /// exits without reading all of it is not an error.
    #[must_use]
    pub fn with_stdin(mut self, stdin: impl Into<Vec<u8>>) -> Self {
        self.stdin = stdin.into();
        self
    }

    /// Sets a variable in addition to the [`Hooks`] environment, overriding it.
    #[must_use]
    pub fn with_env(mut self, name: impl Into<OsString>, value: impl Into<OsString>) -> Self {
        self.environment.push((name.into(), value.into()));
        self
    }

    /// Returns the arguments.
    pub fn arguments(&self) -> &[OsString] {
        &self.arguments
    }

    /// Returns the standard input bytes.
    pub fn stdin(&self) -> &[u8] {
        &self.stdin
    }
}

/// The result of [`Hooks::run`] that did not fail.
#[derive(Debug)]
#[non_exhaustive]
pub enum Outcome {
    /// [`Policy::Ignore`] skipped the named hook without looking for it.
    Ignored(String),
    /// No hook of that name exists.
    Absent,
    /// The hook exists but is not executable, so it did not run. Git prints a hint.
    NotExecutable(PathBuf),
    /// The hook ran.
    Ran(Ran),
}

impl Outcome {
    /// Returns whether an operation that a failing hook blocks may continue: true unless the hook
    /// ran and exited unsuccessfully.
    pub fn allows(&self) -> bool {
        match self {
            Self::Ran(ran) => ran.success(),
            Self::Ignored(_) | Self::Absent | Self::NotExecutable(_) => true,
        }
    }
}

/// A hook that ran to completion.
#[derive(Debug)]
pub struct Ran {
    status: std::process::ExitStatus,
    output: Vec<u8>,
}

impl Ran {
    /// Returns whether the hook exited with status zero.
    pub fn success(&self) -> bool {
        self.status.success()
    }

    /// Returns the exit code, or `None` when a signal ended the hook.
    pub fn code(&self) -> Option<i32> {
        self.status.code()
    }

    /// Returns the exit status.
    pub fn status(&self) -> std::process::ExitStatus {
        self.status
    }

    /// Returns standard output followed by standard error, truncated to the output limit.
    pub fn output(&self) -> &[u8] {
        &self.output
    }
}

/// A hook could not be run.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    /// [`Policy::Refuse`] found an executable hook.
    #[error("hook {hook} would run, but running hooks was refused")]
    Refused {
        /// The hook name.
        hook: String,
        /// Its path.
        path: PathBuf,
    },
    /// `core.hooksPath` uses path expansion girt cannot perform.
    #[error("unsupported core.hooksPath: {0}")]
    HooksPath(&'static str),
    /// This platform cannot run hooks.
    #[error("running hook {0} is not supported on this platform")]
    Unsupported(String),
    /// Starting, feeding or waiting for the hook failed, or the wait was cancelled.
    #[error("running hook {hook}")]
    Process {
        /// The hook name.
        hook: String,
        /// The failure.
        #[source]
        source: std::io::Error,
    },
    /// A hook whose failure stops the operation exited unsuccessfully; see
    /// [`Hooks::run_blocking`].
    #[error("hook {hook} failed{}", code.map(|code| format!(" with exit code {code}")).unwrap_or_default())]
    Rejected {
        /// The hook name.
        hook: String,
        /// The exit code, or `None` when a signal ended the hook.
        code: Option<i32>,
        /// Standard output followed by standard error, truncated to the output limit.
        output: Vec<u8>,
    },
}

/// Converts configuration bytes to a hook argument; non-Unix platforms lose invalid UTF-8.
#[cfg(unix)]
pub(crate) fn os_string(bytes: &[u8]) -> OsString {
    use std::os::unix::ffi::OsStrExt as _;
    std::ffi::OsStr::from_bytes(bytes).to_owned()
}

#[cfg(not(unix))]
pub(crate) fn os_string(bytes: &[u8]) -> OsString {
    String::from_utf8_lossy(bytes).into_owned().into()
}

#[cfg(unix)]
fn path_from_bytes(bytes: &[u8]) -> PathBuf {
    use std::os::unix::ffi::OsStrExt as _;
    PathBuf::from(std::ffi::OsStr::from_bytes(bytes))
}

#[cfg(not(unix))]
fn path_from_bytes(bytes: &[u8]) -> PathBuf {
    PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
}

#[cfg(unix)]
fn is_executable(metadata: &std::fs::Metadata) -> bool {
    use std::os::unix::fs::PermissionsExt as _;
    metadata.permissions().mode() & 0o111 != 0
}

#[cfg(not(unix))]
fn is_executable(_: &std::fs::Metadata) -> bool {
    true
}