git_branchless_hook/
lib.rs

1//! Callbacks for Git hooks.
2//!
3//! Git uses "hooks" to run user-defined scripts after certain events. We
4//! extensively use these hooks to track user activity and e.g. decide if a
5//! commit should be considered obsolete.
6//!
7//! The hooks are installed by the `branchless init` command. This module
8//! contains the implementations for the hooks.
9
10#![warn(missing_docs)]
11#![warn(
12    clippy::all,
13    clippy::as_conversions,
14    clippy::clone_on_ref_ptr,
15    clippy::dbg_macro
16)]
17#![allow(clippy::too_many_arguments, clippy::blocks_in_if_conditions)]
18
19use std::fmt::Write;
20use std::fs::File;
21use std::io::{stdin, BufRead};
22use std::time::SystemTime;
23
24use eyre::Context;
25use git_branchless_invoke::CommandContext;
26use git_branchless_opts::{HookArgs, HookSubcommand};
27use itertools::Itertools;
28use lib::core::dag::Dag;
29use lib::core::repo_ext::RepoExt;
30use lib::core::rewrite::rewrite_hooks::get_deferred_commits_path;
31use lib::util::EyreExitOr;
32use tracing::{error, instrument, warn};
33
34use lib::core::eventlog::{should_ignore_ref_updates, Event, EventLogDb, EventReplayer};
35use lib::core::formatting::{Glyphs, Pluralize};
36use lib::core::gc::{gc, mark_commit_reachable};
37use lib::git::{CategorizedReferenceName, MaybeZeroOid, NonZeroOid, ReferenceName, Repo};
38
39use lib::core::effects::Effects;
40pub use lib::core::rewrite::rewrite_hooks::{
41    hook_drop_commit_if_empty, hook_post_rewrite, hook_register_extra_post_rewrite_hook,
42    hook_skip_upstream_applied_commit,
43};
44
45/// Handle Git's `post-checkout` hook.
46///
47/// See the man-page for `githooks(5)`.
48#[instrument]
49fn hook_post_checkout(
50    effects: &Effects,
51    previous_head_oid: &str,
52    current_head_oid: &str,
53    is_branch_checkout: isize,
54) -> eyre::Result<()> {
55    if is_branch_checkout == 0 {
56        return Ok(());
57    }
58
59    let now = SystemTime::now();
60    let timestamp = now.duration_since(SystemTime::UNIX_EPOCH)?;
61    writeln!(
62        effects.get_output_stream(),
63        "branchless: processing checkout"
64    )?;
65
66    let repo = Repo::from_current_dir()?;
67    let conn = repo.get_db_conn()?;
68    let event_log_db = EventLogDb::new(&conn)?;
69    let event_tx_id = event_log_db.make_transaction_id(now, "hook-post-checkout")?;
70    event_log_db.add_events(vec![Event::RefUpdateEvent {
71        timestamp: timestamp.as_secs_f64(),
72        event_tx_id,
73        old_oid: previous_head_oid.parse()?,
74        new_oid: {
75            let oid: MaybeZeroOid = current_head_oid.parse()?;
76            oid
77        },
78        ref_name: ReferenceName::from("HEAD"),
79        message: None,
80    }])?;
81    Ok(())
82}
83
84fn hook_post_commit_common(effects: &Effects, hook_name: &str) -> eyre::Result<()> {
85    let now = SystemTime::now();
86    let glyphs = Glyphs::detect();
87    let repo = Repo::from_current_dir()?;
88    let conn = repo.get_db_conn()?;
89    let event_log_db = EventLogDb::new(&conn)?;
90
91    let commit_oid = match repo.get_head_info()?.oid {
92        Some(commit_oid) => commit_oid,
93        None => {
94            // A strange situation, but technically possible.
95            warn!(
96                "`{}` hook called, but could not determine the OID of `HEAD`",
97                hook_name
98            );
99            return Ok(());
100        }
101    };
102
103    let commit = repo
104        .find_commit_or_fail(commit_oid)
105        .wrap_err("Looking up `HEAD` commit")?;
106    mark_commit_reachable(&repo, commit_oid)
107        .wrap_err("Marking commit as reachable for GC purposes")?;
108
109    let event_replayer = EventReplayer::from_event_log_db(effects, &repo, &event_log_db)?;
110    let event_cursor = event_replayer.make_default_cursor();
111    let references_snapshot = repo.get_references_snapshot()?;
112    Dag::open_and_sync(
113        effects,
114        &repo,
115        &event_replayer,
116        event_cursor,
117        &references_snapshot,
118    )?;
119
120    if repo.is_rebase_underway()? {
121        let deferred_commits_path = get_deferred_commits_path(&repo);
122        let mut deferred_commits_file = File::options()
123            .create(true)
124            .append(true)
125            .open(&deferred_commits_path)
126            .with_context(|| {
127                format!("Opening deferred commits file at {deferred_commits_path:?}")
128            })?;
129
130        use std::io::Write;
131        writeln!(deferred_commits_file, "{commit_oid}")?;
132        return Ok(());
133    }
134
135    let timestamp = commit.get_time().to_system_time()?;
136
137    // Potentially lossy conversion. The semantics are to round to the nearest
138    // possible float:
139    // https://doc.rust-lang.org/reference/expressions/operator-expr.html#semantics.
140    // We don't rely on the timestamp's correctness for anything, so this is
141    // okay.
142    let timestamp = timestamp
143        .duration_since(SystemTime::UNIX_EPOCH)?
144        .as_secs_f64();
145
146    let event_tx_id = event_log_db.make_transaction_id(now, hook_name)?;
147    event_log_db.add_events(vec![Event::CommitEvent {
148        timestamp,
149        event_tx_id,
150        commit_oid: commit.get_oid(),
151    }])?;
152    writeln!(
153        effects.get_output_stream(),
154        "branchless: processed commit: {}",
155        glyphs.render(commit.friendly_describe(&glyphs)?)?,
156    )?;
157
158    Ok(())
159}
160
161/// Handle Git's `post-commit` hook.
162///
163/// See the man-page for `githooks(5)`.
164#[instrument]
165fn hook_post_commit(effects: &Effects) -> eyre::Result<()> {
166    hook_post_commit_common(effects, "post-commit")
167}
168
169/// Handle Git's `post-merge` hook. It seems that Git doesn't invoke the
170/// `post-commit` hook after a merge commit, so we need to handle this case
171/// explicitly with another hook.
172///
173/// See the man-page for `githooks(5)`.
174#[instrument]
175fn hook_post_merge(effects: &Effects, _is_squash_merge: isize) -> eyre::Result<()> {
176    hook_post_commit_common(effects, "post-merge")
177}
178
179/// Handle Git's `post-applypatch` hook.
180///
181/// See the man-page for `githooks(5)`.
182#[instrument]
183fn hook_post_applypatch(effects: &Effects) -> eyre::Result<()> {
184    hook_post_commit_common(effects, "post-applypatch")
185}
186
187mod reference_transaction {
188    use std::collections::HashMap;
189    use std::fs::File;
190    use std::io::{BufRead, BufReader};
191    use std::str::FromStr;
192
193    use eyre::Context;
194    use itertools::Itertools;
195    use lazy_static::lazy_static;
196    use tracing::{instrument, warn};
197
198    use lib::git::{MaybeZeroOid, ReferenceName, Repo};
199
200    /// A reference target parsed from a reference line.
201    #[derive(Clone, Debug, PartialEq, Eq)]
202    pub enum ReferenceTarget {
203        /// A reference target that was in the transaction line as a normal commit hash.
204        Direct { oid: MaybeZeroOid },
205
206        /// A reference target that was in the transaction line as a symbolic
207        /// reference: `ref:<refname>`.
208        Symbolic { name: ReferenceName },
209    }
210
211    impl ReferenceTarget {
212        /// Attempt to convert the provided reference target into an OID hash value.
213        ///
214        /// For [ReferenceTarget::Direct] types, this always succeeds, and just
215        /// returns the wrapped OID.
216        ///
217        /// For [ReferenceTarget::Symbolic] types, this attempts to convert the
218        /// provided symbolic ref name into an OID hash using the provided
219        /// [Repo].
220        #[instrument]
221        pub fn as_oid(&self, repo: &Repo) -> eyre::Result<MaybeZeroOid> {
222            match self {
223                ReferenceTarget::Direct { oid } => Ok(*oid),
224                ReferenceTarget::Symbolic { name } => Ok(repo.reference_name_to_oid(name)?),
225            }
226        }
227    }
228
229    impl FromStr for ReferenceTarget {
230        type Err = eyre::ErrReport;
231
232        /// Attempts to parse a string as a [ReferenceTarget].
233        /// The string is expected to be one field from a reference transaction
234        /// line, which can be one of the following values:
235        ///
236        /// ref:<symbolic ref name>
237        /// <commit hash>
238        fn from_str(value: &str) -> Result<Self, Self::Err> {
239            match value.strip_prefix("ref:") {
240                Some(refname) => Ok(ReferenceTarget::Symbolic {
241                    name: ReferenceName::from(refname),
242                }),
243                None => Ok(ReferenceTarget::Direct {
244                    oid: value.parse()?,
245                }),
246            }
247        }
248    }
249
250    #[instrument]
251    fn parse_packed_refs_line(line: &str) -> Option<(ReferenceName, MaybeZeroOid)> {
252        if line.is_empty() {
253            return None;
254        }
255        if line.starts_with('#') {
256            // The leading `# pack-refs with:` pragma.
257            return None;
258        }
259        if line.starts_with('^') {
260            // A peeled ref
261            // FIXME actually support peeled refs in packed-refs
262            return None;
263        }
264        if !line.starts_with(|c: char| c.is_ascii_hexdigit()) {
265            warn!(?line, "Unrecognized pack-refs line starting character");
266            return None;
267        }
268
269        lazy_static! {
270            static ref RE: regex::Regex = regex::Regex::new(r"^([^ ]+) (.+)$").unwrap();
271        };
272        match RE.captures(line) {
273            None => {
274                warn!(?line, "No regex match for pack-refs line");
275                None
276            }
277
278            Some(captures) => {
279                let oid = &captures[1];
280                let oid = match MaybeZeroOid::from_str(oid) {
281                    Ok(oid) => oid,
282                    Err(err) => {
283                        warn!(?oid, ?err, "Could not parse OID for pack-refs line");
284                        return None;
285                    }
286                };
287
288                let reference_name = &captures[2];
289                let reference_name = ReferenceName::from(reference_name);
290
291                Some((reference_name, oid))
292            }
293        }
294    }
295
296    #[cfg(test)]
297    #[test]
298    fn test_parse_packed_refs_line() {
299        use super::*;
300
301        let line = "1234567812345678123456781234567812345678 refs/foo/bar";
302        let name = ReferenceName::from("refs/foo/bar");
303        let oid = MaybeZeroOid::from_str("1234567812345678123456781234567812345678").unwrap();
304        assert_eq!(parse_packed_refs_line(line), Some((name, oid)));
305    }
306
307    #[instrument]
308    pub fn read_packed_refs_file(
309        repo: &Repo,
310    ) -> eyre::Result<HashMap<ReferenceName, MaybeZeroOid>> {
311        let packed_refs_file_path = repo.get_packed_refs_path();
312        let file = match File::open(packed_refs_file_path) {
313            Ok(file) => file,
314            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
315            Err(err) => return Err(err.into()),
316        };
317
318        let reader = BufReader::new(file);
319        let mut result = HashMap::new();
320        for line in reader.lines() {
321            let line = line.wrap_err("Reading line from packed-refs")?;
322            if line.is_empty() {
323                continue;
324            }
325            if let Some((k, v)) = parse_packed_refs_line(&line) {
326                result.insert(k, v);
327            }
328        }
329        Ok(result)
330    }
331
332    #[derive(Debug, PartialEq, Eq)]
333    pub struct ParsedReferenceTransactionLine {
334        pub ref_name: ReferenceName,
335        pub old_value: ReferenceTarget,
336        pub new_value: ReferenceTarget,
337    }
338
339    #[instrument]
340    pub fn parse_reference_transaction_line(
341        line: &str,
342    ) -> eyre::Result<ParsedReferenceTransactionLine> {
343        let fields = line.split(' ').collect_vec();
344        match fields.as_slice() {
345            [old_value, new_value, ref_name] => Ok(ParsedReferenceTransactionLine {
346                ref_name: ReferenceName::from(*ref_name),
347                old_value: ReferenceTarget::from_str(old_value)?,
348                new_value: ReferenceTarget::from_str(new_value)?,
349            }),
350            _ => {
351                eyre::bail!(
352                    "Unexpected number of fields in reference-transaction line: {:?}",
353                    &line
354                )
355            }
356        }
357    }
358
359    #[cfg(test)]
360    #[test]
361    fn test_parse_reference_transaction_line() -> eyre::Result<()> {
362        use lib::{core::eventlog::should_ignore_ref_updates, testing::make_git};
363
364        let git = make_git()?;
365        git.init_repo()?;
366        let oid1 = git.commit_file("README", 1)?;
367        let oid2 = git.commit_file("README2", 2)?;
368
369        let zero = "0000000000000000000000000000000000000000";
370        let branch_ref = "refs/heads/mybranch";
371        let orig_head_ref = "ORIG_HEAD";
372        let master_tx_ref = "ref:refs/heads/master";
373        let master_ref = "refs/heads/master";
374        let head_ref = "HEAD";
375
376        let line = format!("{oid1} {oid2} {branch_ref}");
377        assert_eq!(
378            parse_reference_transaction_line(&line)?,
379            ParsedReferenceTransactionLine {
380                old_value: ReferenceTarget::Direct { oid: oid1.into() },
381                new_value: ReferenceTarget::Direct { oid: oid2.into() },
382                ref_name: ReferenceName::from(branch_ref),
383            }
384        );
385
386        let line = format!("{zero} {master_tx_ref} HEAD");
387        assert_eq!(
388            parse_reference_transaction_line(&line)?,
389            ParsedReferenceTransactionLine {
390                old_value: ReferenceTarget::Direct { oid: zero.parse()? },
391                new_value: ReferenceTarget::Symbolic {
392                    name: ReferenceName::from(master_ref)
393                },
394                ref_name: ReferenceName::from(head_ref)
395            }
396        );
397
398        {
399            let line = &format!("{oid1} {oid2} ORIG_HEAD");
400            let parsed_line = parse_reference_transaction_line(line)?;
401            assert_eq!(
402                parsed_line,
403                ParsedReferenceTransactionLine {
404                    old_value: ReferenceTarget::Direct { oid: oid1.into() },
405                    new_value: ReferenceTarget::Direct { oid: oid2.into() },
406                    ref_name: ReferenceName::from(orig_head_ref)
407                }
408            );
409            assert!(should_ignore_ref_updates(&parsed_line.ref_name));
410        }
411
412        let line = "there are not three fields here";
413        assert!(parse_reference_transaction_line(line).is_err());
414
415        Ok(())
416    }
417
418    fn reftarget_matches_refname(
419        reftarget: &ReferenceTarget,
420        refname: &ReferenceName,
421        packed_references: &HashMap<ReferenceName, MaybeZeroOid>,
422    ) -> bool {
423        match reftarget {
424            ReferenceTarget::Direct { oid } => packed_references.get(refname) == Some(oid),
425            ReferenceTarget::Symbolic { name } => name == refname,
426        }
427    }
428    /// As per the discussion at
429    /// https://public-inbox.org/git/CAKjfCeBcuYC3OXRVtxxDGWRGOxC38Fb7CNuSh_dMmxpGVip_9Q@mail.gmail.com/,
430    /// the OIDs passed to the reference transaction can't actually be trusted
431    /// when dealing with packed references, so we need to look up their actual
432    /// values on disk again. See https://git-scm.com/docs/git-pack-refs for
433    /// details about packed references.
434    ///
435    /// Supposing we have a ref named `refs/heads/foo` pointing to an OID
436    /// `abc123`, when references are packed, we'll first see a transaction like
437    /// this:
438    ///
439    /// ```text
440    /// 000000 abc123 refs/heads/foo
441    /// ```
442    ///
443    /// And immediately afterwards see a transaction like this:
444    ///
445    /// ```text
446    /// abc123 000000 refs/heads/foo
447    /// ```
448    ///
449    /// If considered naively, this would suggest that the reference was created
450    /// (even though it already exists!) and then deleted (even though it still
451    /// exists!).
452    #[instrument]
453    pub fn fix_packed_reference_oid(
454        repo: &Repo,
455        packed_references: &HashMap<ReferenceName, MaybeZeroOid>,
456        parsed_line: ParsedReferenceTransactionLine,
457    ) -> ParsedReferenceTransactionLine {
458        match parsed_line {
459            ParsedReferenceTransactionLine {
460                ref_name,
461                old_value:
462                    ReferenceTarget::Direct {
463                        oid: MaybeZeroOid::Zero,
464                    },
465                new_value,
466            } if reftarget_matches_refname(&new_value, &ref_name, packed_references) => {
467                // The reference claims to have been created, but it appears to
468                // already be in the `packed-refs` file with that OID. Most
469                // likely it was being packed in this operation.
470                ParsedReferenceTransactionLine {
471                    ref_name,
472                    old_value: new_value.clone(),
473                    new_value: new_value.clone(),
474                }
475            }
476
477            ParsedReferenceTransactionLine {
478                ref_name,
479                old_value,
480                new_value:
481                    ReferenceTarget::Direct {
482                        oid: MaybeZeroOid::Zero,
483                    },
484            } if reftarget_matches_refname(&old_value, &ref_name, packed_references) => {
485                // The reference claims to have been deleted, but it's still in
486                // the `packed-refs` file with that OID. Most likely it was
487                // being packed in this operation.
488                ParsedReferenceTransactionLine {
489                    ref_name,
490                    old_value: old_value.clone(),
491                    new_value: old_value.clone(),
492                }
493            }
494
495            other => other,
496        }
497    }
498}
499
500/// Handle Git's `reference-transaction` hook.
501///
502/// See the man-page for `githooks(5)`.
503#[instrument]
504fn hook_reference_transaction(effects: &Effects, transaction_state: &str) -> eyre::Result<()> {
505    use reference_transaction::{
506        fix_packed_reference_oid, parse_reference_transaction_line, read_packed_refs_file,
507        ParsedReferenceTransactionLine,
508    };
509
510    if transaction_state != "committed" {
511        return Ok(());
512    }
513    let now = SystemTime::now();
514
515    let repo = Repo::from_current_dir()?;
516    let conn = repo.get_db_conn()?;
517    let event_log_db = EventLogDb::new(&conn)?;
518    let event_tx_id = event_log_db.make_transaction_id(now, "reference-transaction")?;
519
520    let packed_references = read_packed_refs_file(&repo)?;
521
522    let parsed_lines: Vec<ParsedReferenceTransactionLine> = stdin()
523        .lock()
524        .split(b'\n')
525        .filter_map(|line| {
526            let line = match line {
527                Ok(line) => line,
528                Err(_) => return None,
529            };
530            let line = match std::str::from_utf8(&line) {
531                Ok(line) => line,
532                Err(err) => {
533                    error!(?err, ?line, "Could not parse reference-transaction line");
534                    return None;
535                }
536            };
537            match parse_reference_transaction_line(line) {
538                Ok(line) => Some(line),
539                Err(err) => {
540                    error!(?err, ?line, "Could not parse reference-transaction-line");
541                    None
542                }
543            }
544        })
545        .filter(
546            |ParsedReferenceTransactionLine {
547                 ref_name,
548                 old_value: _,
549                 new_value: _,
550             }| !should_ignore_ref_updates(ref_name),
551        )
552        .map(|parsed_line| fix_packed_reference_oid(&repo, &packed_references, parsed_line))
553        .collect();
554    if parsed_lines.is_empty() {
555        return Ok(());
556    }
557
558    let num_reference_updates = Pluralize {
559        determiner: None,
560        amount: parsed_lines.len(),
561        unit: ("update", "updates"),
562    };
563    writeln!(
564        effects.get_output_stream(),
565        "branchless: processing {}: {}",
566        num_reference_updates,
567        parsed_lines
568            .iter()
569            .map(
570                |ParsedReferenceTransactionLine {
571                     ref_name,
572                     old_value: _,
573                     new_value: _,
574                 }| { CategorizedReferenceName::new(ref_name).friendly_describe() }
575            )
576            .map(|description| format!("{}", console::style(description).green()))
577            .sorted()
578            .collect::<Vec<_>>()
579            .join(", ")
580    )?;
581
582    let timestamp = now
583        .duration_since(SystemTime::UNIX_EPOCH)
584        .wrap_err("Calculating timestamp")?
585        .as_secs_f64();
586    let events: eyre::Result<Vec<Event>> = parsed_lines
587        .into_iter()
588        .map(
589            |ParsedReferenceTransactionLine {
590                 ref_name,
591                 old_value,
592                 new_value,
593             }| {
594                let old_oid = old_value.as_oid(&repo)?;
595                let new_oid = new_value.as_oid(&repo)?;
596                Ok(Event::RefUpdateEvent {
597                    timestamp,
598                    event_tx_id,
599                    ref_name,
600                    old_oid,
601                    new_oid,
602                    message: None,
603                })
604            },
605        )
606        .collect();
607    event_log_db.add_events(events?)?;
608
609    Ok(())
610}
611
612/// `hook` subcommand.
613#[instrument]
614pub fn command_main(ctx: CommandContext, args: HookArgs) -> EyreExitOr<()> {
615    let CommandContext {
616        effects,
617        git_run_info,
618    } = ctx;
619    let HookArgs { subcommand } = args;
620
621    match subcommand {
622        HookSubcommand::DetectEmptyCommit { old_commit_oid } => {
623            let old_commit_oid: NonZeroOid = old_commit_oid.parse()?;
624            hook_drop_commit_if_empty(&effects, old_commit_oid)?;
625        }
626
627        HookSubcommand::PreAutoGc => {
628            gc(&effects)?;
629        }
630
631        HookSubcommand::PostApplypatch => {
632            hook_post_applypatch(&effects)?;
633        }
634
635        HookSubcommand::PostCheckout {
636            previous_commit,
637            current_commit,
638            is_branch_checkout,
639        } => {
640            hook_post_checkout(
641                &effects,
642                &previous_commit,
643                &current_commit,
644                is_branch_checkout,
645            )?;
646        }
647
648        HookSubcommand::PostCommit => {
649            hook_post_commit(&effects)?;
650        }
651
652        HookSubcommand::PostMerge { is_squash_merge } => {
653            hook_post_merge(&effects, is_squash_merge)?;
654        }
655
656        HookSubcommand::PostRewrite { rewrite_type } => {
657            hook_post_rewrite(&effects, &git_run_info, &rewrite_type)?;
658        }
659
660        HookSubcommand::ReferenceTransaction { transaction_state } => {
661            hook_reference_transaction(&effects, &transaction_state)?;
662        }
663
664        HookSubcommand::RegisterExtraPostRewriteHook => {
665            hook_register_extra_post_rewrite_hook()?;
666        }
667
668        HookSubcommand::SkipUpstreamAppliedCommit { commit_oid } => {
669            let commit_oid: NonZeroOid = commit_oid.parse()?;
670            hook_skip_upstream_applied_commit(&effects, commit_oid)?;
671        }
672    }
673
674    Ok(Ok(()))
675}