prek 0.5.5

A fast Git hook manager written in Rust, designed as a drop-in alternative to pre-commit, reimagined.
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
use std::ffi::OsString;
use std::fmt::Write;
use std::ops::RangeInclusive;
use std::path::{Path, PathBuf};
use std::process::Stdio;

use anstream::eprintln;
use anyhow::Result;
use itertools::Itertools;
use owo_colors::OwoColorize;
use prek_consts::env_vars::{EnvVars, EnvVarsRead};
use tokio::io::{AsyncReadExt, AsyncWriteExt};

use crate::cli::{self, ExitStatus, RunArgs, RunOptions};
use crate::config::HookType;
use crate::fs::CWD;
use crate::languages::resolve_command;
use crate::printer::Printer;
use crate::process::Cmd;
use crate::store::Store;
use crate::workspace;
use crate::workspace::Project;
use crate::{git, warn_user};

pub(crate) async fn hook_impl(
    store: &Store,
    config: Option<PathBuf>,
    includes: Vec<String>,
    skips: Vec<String>,
    hook_type: HookType,
    hook_dir: Option<PathBuf>,
    skip_on_missing_config: bool,
    script_version: Option<usize>,
    args: Vec<OsString>,
    printer: Printer,
) -> Result<ExitStatus> {
    let stdin = read_hook_stdin(hook_type).await?;
    let legacy_code = run_legacy(hook_type, hook_dir.as_deref(), &args, &stdin).await?;

    if let Some(script_version) = script_version
        && script_version != cli::install::CUR_SCRIPT_VERSION
    {
        warn_user!(
            "The installed Git shim `{hook_type}` is outdated (version: {script_version}, expected: {}). Please reinstall the Git shims with `prek install`.",
            cli::install::CUR_SCRIPT_VERSION
        );
    }

    let allow_missing_config =
        skip_on_missing_config || EnvVars.is_set(EnvVars::PREK_ALLOW_NO_CONFIG);
    let warn_for_no_config = || {
        eprintln!(
            "- To temporarily silence this, run `{}`",
            format!("{}=1 git ...", EnvVars::PREK_ALLOW_NO_CONFIG).cyan()
        );
        eprintln!(
            "- To permanently silence this, install hooks with the `{}` flag",
            "--allow-missing-config".cyan()
        );
        eprintln!("- To uninstall hooks, run `{}`", "prek uninstall".cyan());
    };

    // Check if there is config file
    if let Some(ref config) = config {
        if !config.try_exists()? {
            return if allow_missing_config {
                Ok(legacy_code.into())
            } else {
                eprintln!(
                    "{}: config file not found: `{}`",
                    "error".red().bold(),
                    config.display().cyan()
                );
                warn_for_no_config();

                Ok(ExitStatus::Failure)
            };
        }
        writeln!(printer.stdout(), "Using config file: {}", config.display())?;
    } else {
        // Try to discover a project from current directory (after `--cd`)
        match Project::discover(config.as_deref(), &CWD) {
            Err(e @ workspace::Error::MissingConfigFile) => {
                return if allow_missing_config {
                    Ok(legacy_code.into())
                } else {
                    eprintln!("{}: {e}", "error".red().bold());
                    warn_for_no_config();

                    Ok(ExitStatus::Failure)
                };
            }
            Ok(project) => {
                if project.path() != git::root()? {
                    writeln!(
                        printer.stdout(),
                        "Running in workspace: `{}`",
                        project.path().display().cyan()
                    )?;
                }
            }
            Err(e) => return Err(e.into()),
        }
    }

    let expected_args = hook_num_args(hook_type);
    if !expected_args.contains(&args.len()) {
        anyhow::bail!(
            "hook `{}` expects {} but received {}{}",
            hook_type.to_string().cyan(),
            format_expected_args(expected_args),
            format_received_args(args.len()),
            format_argument_dump(&args)
        );
    }

    let Some(mut run_args) = to_run_args(hook_type, &args, &stdin).await? else {
        return Ok(legacy_code.into());
    };
    run_args.includes = includes;
    run_args.skips = skips;

    let status = cli::run(
        store,
        config,
        RunArgs {
            options: run_args,
            stage: Some(hook_type.into()),
            ..RunArgs::default()
        },
        false,
        false,
        printer,
    )
    .await?;

    Ok(if !matches!(status, ExitStatus::Success) {
        status
    } else {
        legacy_code.into()
    })
}

fn hook_num_args(hook_type: HookType) -> RangeInclusive<usize> {
    match hook_type {
        HookType::CommitMsg => 1..=1,
        HookType::PostCheckout => 3..=3,
        HookType::PreCommit => 0..=0,
        HookType::PostCommit => 0..=0,
        HookType::PreMergeCommit => 0..=0,
        HookType::PostMerge => 1..=1,
        HookType::PostRewrite => 1..=1,
        HookType::PrePush => 2..=2,
        HookType::PreRebase => 1..=2,
        HookType::PrepareCommitMsg => 1..=3,
    }
}

async fn read_hook_stdin(hook_type: HookType) -> Result<Vec<u8>> {
    if !matches!(hook_type, HookType::PrePush) {
        return Ok(vec![]);
    }

    let mut stdin = tokio::io::stdin();
    let mut buffer = vec![];
    stdin.read_to_end(&mut buffer).await?;
    Ok(buffer)
}

async fn run_legacy(
    hook_type: HookType,
    hook_dir: Option<&Path>,
    args: &[OsString],
    stdin: &[u8],
) -> Result<u8> {
    if EnvVars.is_set(EnvVars::PREK_RUNNING_LEGACY) {
        anyhow::bail!(
            "prek's Git shim is installed in migration mode\n\
            run `prek install -f --hook-type {hook_type}` to reinstall the shim"
        );
    }

    // `prek hook-impl` without `--hook-dir` is likely invoked from Git 2.54+
    // config-based hooks, where there is no hook script directory to inspect.
    // Skip legacy hooks in that case.
    let Some(hook_dir) = hook_dir else {
        return Ok(0);
    };
    let legacy_hook = hook_dir.join(format!("{hook_type}.legacy"));
    let metadata = match fs_err::tokio::metadata(&legacy_hook).await {
        Ok(metadata) => metadata,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            // No legacy hook, so skip running it.
            return Ok(0);
        }
        Err(e) => return Err(e.into()),
    };
    let executable;
    #[cfg(unix)]
    {
        executable = crate::fs::has_executable_bit(&metadata);
    }
    #[cfg(not(unix))]
    {
        executable = true;
        _ = metadata;
    }
    if !executable {
        return Ok(0);
    }

    let entry = resolve_command(vec![legacy_hook.into_os_string()], None, &CWD);
    let mut cmd = Cmd::new(&entry[0]);
    cmd.check(false).args(&entry[1..]).args(args);
    cmd.env(EnvVars::PREK_RUNNING_LEGACY, "1");

    let status = if stdin.is_empty() {
        cmd.status().await?
    } else {
        cmd.stdin(Stdio::piped());
        let mut child = cmd.spawn()?;
        if let Some(mut child_stdin) = child.stdin.take() {
            child_stdin.write_all(stdin).await?;
        }
        child.wait().await?
    };

    Ok(status
        .code()
        .and_then(|code| u8::try_from(code).ok())
        .unwrap_or(1))
}

async fn to_run_args(
    hook_type: HookType,
    args: &[OsString],
    stdin: &[u8],
) -> Result<Option<RunOptions>> {
    let mut run_args = RunOptions::default();

    match hook_type {
        HookType::PrePush => {
            // https://git-scm.com/docs/githooks#_pre_push
            run_args.extra.remote_name = Some(args[0].to_string_lossy().into_owned());
            run_args.extra.remote_url = Some(args[1].to_string_lossy().into_owned());

            if let Some(push_info) = parse_pre_push_info(&args[0].to_string_lossy(), stdin).await? {
                run_args.file_selection.from_ref = push_info.from_ref;
                run_args.file_selection.to_ref = push_info.to_ref;
                run_args.file_selection.all_files = push_info.all_files;
                run_args.extra.remote_branch = push_info.remote_branch;
                run_args.extra.local_branch = push_info.local_branch;
            } else {
                // Nothing to push
                return Ok(None);
            }
        }
        HookType::CommitMsg => {
            run_args.extra.commit_msg_filename = Some(args[0].to_string_lossy().into_owned());
        }
        HookType::PrepareCommitMsg => {
            run_args.extra.commit_msg_filename = Some(args[0].to_string_lossy().into_owned());
            if args.len() > 1 {
                run_args.extra.prepare_commit_message_source =
                    Some(args[1].to_string_lossy().into_owned());
            }
            if args.len() > 2 {
                run_args.extra.commit_object_name = Some(args[2].to_string_lossy().into_owned());
            }
        }
        HookType::PostCheckout => {
            run_args.file_selection.from_ref = Some(args[0].to_string_lossy().into_owned());
            run_args.file_selection.to_ref = Some(args[1].to_string_lossy().into_owned());
            run_args.extra.checkout_type = Some(args[2].to_string_lossy().into_owned());
        }
        HookType::PostMerge => run_args.extra.is_squash_merge = args[0] == "1",
        HookType::PostRewrite => {
            run_args.extra.rewrite_command = Some(args[0].to_string_lossy().into_owned());
        }
        HookType::PreRebase => {
            run_args.extra.pre_rebase_upstream = Some(args[0].to_string_lossy().into_owned());
            if args.len() > 1 {
                run_args.extra.pre_rebase_branch = Some(args[1].to_string_lossy().into_owned());
            }
        }
        HookType::PostCommit | HookType::PreMergeCommit | HookType::PreCommit => {}
    }

    Ok(Some(run_args))
}

#[derive(Debug)]
struct PushInfo {
    from_ref: Option<String>,
    to_ref: Option<String>,
    all_files: bool,
    remote_branch: Option<String>,
    local_branch: Option<String>,
}

async fn parse_pre_push_info(remote_name: &str, stdin: &[u8]) -> Result<Option<PushInfo>> {
    let buffer = String::from_utf8_lossy(stdin);

    for line in buffer.lines() {
        let parts: Vec<&str> = line.rsplitn(4, ' ').collect();
        if parts.len() != 4 {
            // Ignore malformed lines from stdin; a later valid line may still describe a push.
            continue;
        }

        let local_branch = parts[3];
        let local_sha = parts[2];
        let remote_branch = parts[1];
        let remote_sha = parts[0];

        // A zero local SHA means this push deletes the remote ref. There is no local
        // target commit to diff, so it contributes no files to check.
        if local_sha.bytes().all(|b| b == b'0') {
            continue;
        }

        if !remote_sha.bytes().all(|b| b == b'0') && git::rev_exists(remote_sha).await? {
            if git::is_ancestor(remote_sha, local_sha).await? {
                // Normal update to an existing remote ref: the previous remote tip is
                // an ancestor of the new local tip, so diff exactly the newly pushed range.
                return Ok(Some(PushInfo {
                    from_ref: Some(remote_sha.to_string()),
                    to_ref: Some(local_sha.to_string()),
                    all_files: false,
                    remote_branch: Some(remote_branch.to_string()),
                    local_branch: Some(local_branch.to_string()),
                }));
            }

            // The old remote tip exists locally but is not in the new local history,
            // which is the usual rebase/force-push shape. Do not diff against it:
            // fall through to the new-branch logic below to derive a PR-like base.
        }

        // New remote ref, missing old remote object, or rebased force-push: find the
        // commits reachable from the local tip that this remote cannot already reach.
        let ancestors = git::ancestors_not_in_remote(local_sha, remote_name).await?;
        if ancestors.is_empty() {
            // The local tip is already reachable from the remote, so this line does
            // not introduce files that need pre-push checks.
            continue;
        }

        let first_ancestor = &ancestors[0];
        let roots = git::root_commits(local_sha).await?;

        if roots.contains(first_ancestor) {
            // The first commit being pushed is a root commit. There is no parent to
            // use as from_ref, so run hooks over the full tracked tree.
            return Ok(Some(PushInfo {
                from_ref: None,
                to_ref: Some(local_sha.to_string()),
                all_files: true,
                remote_branch: Some(remote_branch.to_string()),
                local_branch: Some(local_branch.to_string()),
            }));
        }

        // Use the parent of the first remote-unknown commit as the diff base. For
        // rebased force-pushes, this usually resolves to the updated default branch
        // base, matching the files a pull request would show.
        if let Some(source) = git::parent_commit(first_ancestor).await? {
            return Ok(Some(PushInfo {
                from_ref: Some(source),
                to_ref: Some(local_sha.to_string()),
                all_files: false,
                remote_branch: Some(remote_branch.to_string()),
                local_branch: Some(local_branch.to_string()),
            }));
        }

        // A non-root commit should have a parent. If Git cannot provide one, ignore
        // this line and allow any later pushed ref line to determine the hook range.
    }

    // Nothing to push
    Ok(None)
}

fn format_expected_args(range: RangeInclusive<usize>) -> String {
    let (start, end) = (*range.start(), *range.end());
    match (start, end) {
        (0, 0) => "no arguments".to_string(),
        (1, 1) => "exactly 1 argument".to_string(),
        (s, e) if s == e => format!("exactly {s} arguments"),
        (0, e) => format!("up to {e} arguments"),
        (s, usize::MAX) => format!("at least {s} arguments"),
        (s, e) => format!("between {s} and {e} arguments"),
    }
}

fn format_received_args(received: usize) -> String {
    match received {
        0 => "no arguments".to_string(),
        1 => "1 argument".to_string(),
        n => format!("{n} arguments"),
    }
}

fn format_argument_dump(args: &[OsString]) -> String {
    if args.is_empty() {
        String::new()
    } else {
        format!(": `{}`", args.iter().map(|s| s.to_string_lossy()).join(" "))
    }
}