Repository: j178/prefligit (language: python)

Issue:
pre-commit install/uninstall



Discussion / hints:

Fix patch:
diff --git a/src/cli/install.rs b/src/cli/install.rs
index 9261947..04fd222 100644
--- a/src/cli/install.rs
+++ b/src/cli/install.rs
@@ -1,15 +1,186 @@
-use std::path::PathBuf;
+use std::fmt::Write as _;
+use std::io::Write;
+use std::path::{Path, PathBuf};
 
 use anyhow::Result;
+use indoc::indoc;
+use owo_colors::OwoColorize;
 
+use crate::cli::run;
 use crate::cli::{ExitStatus, HookType};
+use crate::fs::Simplified;
+use crate::git;
+use crate::hook::Project;
 use crate::printer::Printer;
+use crate::store::Store;
 
 pub(crate) async fn install(
-    _config: Option<PathBuf>,
-    _hook_type: Vec<HookType>,
-    _install_hooks: bool,
-    _printer: Printer,
+    config: Option<PathBuf>,
+    hook_types: Vec<HookType>,
+    install_hooks: bool,
+    overwrite: bool,
+    allow_missing_config: bool,
+    printer: Printer,
 ) -> Result<ExitStatus> {
-    todo!()
+    if git::has_hooks_path_set().await? {
+        writeln!(
+            printer.stderr(),
+            indoc::indoc! {"
+                Cowardly refusing to install hooks with `core.hooksPath` set.
+                hint: `git config --unset-all core.hooksPath` to fix this.
+            "}
+        )?;
+        return Ok(ExitStatus::Failure);
+    }
+
+    let project = Project::from_config_file(config);
+
+    let mut hook_types = if hook_types.is_empty() {
+        if let Ok(ref project) = project {
+            project
+                .config()
+                .default_install_hook_types
+                .clone()
+                .unwrap_or_default()
+        } else {
+            vec![]
+        }
+    } else {
+        hook_types
+    };
+    if hook_types.is_empty() {
+        hook_types = vec![HookType::PreCommit];
+    }
+
+    let hooks_path = git::get_git_common_dir().await?.join("hooks");
+    fs_err::create_dir_all(&hooks_path)?;
+
+    let config_file = project.as_ref().ok().map(Project::config_file);
+    for hook_type in hook_types {
+        install_hook_script(
+            config_file,
+            hook_type,
+            &hooks_path,
+            overwrite,
+            allow_missing_config,
+            printer,
+        )?;
+    }
+
+    if install_hooks {
+        let mut project = project?;
+        let store = Store::from_settings()?.init()?;
+        let _lock = store.lock_async().await?;
+
+        let hooks = project.init_hooks(&store, printer).await?;
+        run::install_hooks(&hooks, printer).await?;
+    }
+
+    Ok(ExitStatus::Success)
+}
+
+fn install_hook_script(
+    config_file: Option<&Path>,
+    hook_type: HookType,
+    hooks_path: &Path,
+    overwrite: bool,
+    skip_on_missing_config: bool,
+    printer: Printer,
+) -> Result<()> {
+    let hook_path = hooks_path.join(hook_type.as_str());
+
+    if hook_path.try_exists()? {
+        if overwrite {
+            writeln!(
+                printer.stdout(),
+                "Overwriting existing hook at {}",
+                hook_path.user_display().cyan()
+            )?;
+        } else {
+            if !is_our_script(&hook_path)? {
+                let legacy_path = format!("{}.legacy", hook_path.display());
+                fs_err::rename(&hook_path, &legacy_path)?;
+                writeln!(
+                    printer.stdout(),
+                    "Hook already exists at {}, move it to {}.",
+                    hook_path.user_display().cyan(),
+                    legacy_path.user_display().yellow()
+                )?;
+            }
+        }
+    }
+
+    let mut args = vec![
+        "hook-impl".to_string(),
+        format!(
+            "--config={}",
+            config_file.map_or_else(String::new, |p| p.user_display().to_string())
+        ),
+        format!("--hook-type={}", hook_type.as_str()),
+    ];
+    if skip_on_missing_config {
+        args.push("--skip-on-missing-config".to_string());
+    }
+
+    let args = shlex::try_join(args.iter().map(String::as_str))?;
+    let pre_commit = std::env::current_exe()?;
+    let pre_commit = pre_commit.simplified().display().to_string();
+    let hook_script = HOOK_TMPL
+        .replace("ARGS=(hook-impl)", &format!("ARGS=({args})"))
+        .replace(
+            r#"PRE_COMMIT="pre-commit""#,
+            &format!(r#"PRE_COMMIT="{pre_commit}""#),
+        );
+    fs_err::OpenOptions::new()
+        .write(true)
+        .create(true)
+        .truncate(true)
+        .open(&hook_path)?
+        .write_all(hook_script.as_bytes())?;
+
+    #[cfg(unix)]
+    {
+        use std::os::unix::fs::PermissionsExt;
+
+        let mut perms = hook_path.metadata()?.permissions();
+        perms.set_mode(0o755);
+        fs_err::set_permissions(&hook_path, perms)?;
+    }
+
+    writeln!(
+        printer.stdout(),
+        "pre-commit installed at {}",
+        hook_path.user_display().cyan()
+    )?;
+
+    Ok(())
+}
+
+static HOOK_TMPL: &str = indoc! { r#"
+#!/usr/bin/env bash
+# File generated by pre-commit-rs: https://github.com/j178/pre-commit-rs
+# ID: 182c10f181da4464a3eec51b83331688
+
+ARGS=(hook-impl)
+
+HERE="$(cd "$(dirname "$0")" && pwd)"
+ARGS+=(--hook-dir "$HERE" -- "$@")
+PRE_COMMIT="pre-commit"
+
+exec "$PRE_COMMIT" "${ARGS[@]}"
+
+"# };
+
+static PRIOR_HASHES: &[&str] = &[];
+
+// Use a different hash for each change to the script.
+// Use a different hash from `pre-commit` since our script is different.
+static CURRENT_HASH: &str = "182c10f181da4464a3eec51b83331688";
+
+/// Checks if the script contains any of the hashes that `pre-commit` has used in the past.
+fn is_our_script(hook_path: &Path) -> Result<bool> {
+    let content = fs_err::read_to_string(hook_path)?;
+    Ok(std::iter::once(CURRENT_HASH)
+        .chain(PRIOR_HASHES.iter().copied())
+        .any(|hash| content.contains(hash)))
 }
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index 597613b..2b0a22e 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -2,9 +2,9 @@ use std::ffi::OsString;
 use std::path::PathBuf;
 use std::process::ExitCode;
 
-use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum};
+use clap::{ArgAction, Args, Parser, Subcommand};
 
-use crate::config::Stage;
+use crate::config::{HookType, Stage};
 
 mod clean;
 mod install;
@@ -190,27 +190,13 @@ pub(crate) struct InstallArgs {
     pub(crate) install_hooks: bool,
 
     #[arg(short = 't', long, value_enum, value_delimiter = ' ')]
-    pub(crate) hook_type: Vec<HookType>,
+    pub(crate) hook_types: Vec<HookType>,
 
     /// Allow a missing `pre-commit` configuration file.
     #[arg(long)]
     pub(crate) allow_missing_config: bool,
 }
 
-#[derive(Debug, Clone, Copy, ValueEnum)]
-pub(crate) enum HookType {
-    CommitMsg,
-    PostCheckout,
-    PostCommit,
-    PostMerge,
-    PostRewrite,
-    PreCommit,
-    PreMergeCommit,
-    PrePush,
-    PreRebase,
-    PrepareCommitMsg,
-}
-
 #[derive(Debug, Clone, Args)]
 pub(crate) struct RunExtraArgs {
     #[arg(long, hide = true)]
diff --git a/src/cli/run.rs b/src/cli/run.rs
index dc20de6..38e8605 100644
--- a/src/cli/run.rs
+++ b/src/cli/run.rs
@@ -321,7 +321,7 @@ async fn install_hook(hook: &Hook, env_dir: PathBuf, printer: Printer) -> Result
 }
 
 // TODO: progress bar
-async fn install_hooks(hooks: &[Hook], printer: Printer) -> Result<()> {
+pub async fn install_hooks(hooks: &[Hook], printer: Printer) -> Result<()> {
     let to_install = hooks
         .iter()
         .filter(|&hook| !hook.installed())
diff --git a/src/git.rs b/src/git.rs
index 30037fb..9ed8793 100644
--- a/src/git.rs
+++ b/src/git.rs
@@ -99,6 +99,23 @@ pub async fn get_git_dir() -> Result<PathBuf, Error> {
     ))
 }
 
+pub async fn get_git_common_dir() -> Result<PathBuf, Error> {
+    let output = git_cmd()?
+        .arg("rev-parse")
+        .arg("--git-common-dir")
+        .output()
+        .await
+        .map_err(OutputError::with_cause)?
+        .ok()?;
+    if output.stdout.trim_ascii().is_empty() {
+        Ok(get_git_dir().await?)
+    } else {
+        Ok(PathBuf::from(
+            String::from_utf8_lossy(&output.stdout).trim(),
+        ))
+    }
+}
+
 pub async fn get_staged_files() -> Result<Vec<String>, Error> {
     let output = git_cmd()?
         .arg("diff")
@@ -299,3 +316,17 @@ pub async fn clone_repo(url: &str, rev: &str, path: &Path) -> Result<(), Error>
         Ok(())
     }
 }
+
+pub async fn has_hooks_path_set() -> Result<bool> {
+    let output = git_cmd()?
+        .arg("config")
+        .arg("--get")
+        .arg("core.hooksPath")
+        .output()
+        .await?;
+    if output.status.success() {
+        Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
+    } else {
+        Ok(false)
+    }
+}
diff --git a/src/hook.rs b/src/hook.rs
index a6426fe..4cf2ab4 100644
--- a/src/hook.rs
+++ b/src/hook.rs
@@ -112,6 +112,7 @@ pub struct Project {
 }
 
 impl Project {
+    /// Find the configuration file in the given path or the current working directory.
     pub fn find_config_file(config: Option<PathBuf>) -> Result<PathBuf, Error> {
         let file = config.unwrap_or_else(|| CWD.join(CONFIG_FILE));
         if file.try_exists()? {
@@ -121,7 +122,13 @@ impl Project {
         Err(Error::Config(config::Error::NotFound(file)))
     }
 
-    /// Load a project configuration from a directory.
+    /// Initialize a new project from the configuration file or the file in the current working directory.
+    pub fn from_config_file(config: Option<PathBuf>) -> Result<Self, Error> {
+        let config_path = Self::find_config_file(config)?;
+        Self::new(config_path)
+    }
+
+    /// Initialize a new project from the configuration file.
     pub fn new(config_path: PathBuf) -> Result<Self, Error> {
         debug!(
             path = %config_path.display(),
@@ -140,6 +147,10 @@ impl Project {
         &self.config
     }
 
+    pub fn config_file(&self) -> &Path {
+        &self.config_path
+    }
+
     async fn init_repos(&mut self, store: &Store, printer: Printer) -> Result<(), Error> {
         let mut repos = Vec::with_capacity(self.config.repos.len());
 
diff --git a/src/main.rs b/src/main.rs
index 4fde276..4dcc457 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -162,8 +162,10 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
 
             cli::install(
                 cli.globals.config,
-                args.hook_type,
+                args.hook_types,
                 args.install_hooks,
+                args.overwrite,
+                args.allow_missing_config,
                 printer,
             )
             .await


Test patch:
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
index 072c55d..0ee9792 100644
--- a/tests/common/mod.rs
+++ b/tests/common/mod.rs
@@ -46,6 +46,13 @@ impl TestContext {
                 .map(|pattern| (pattern, "[HOME]/".to_string())),
         );
 
+        let current_exe = assert_cmd::cargo::cargo_bin("pre-commit");
+        filters.extend(
+            Self::path_patterns(&current_exe)
+                .into_iter()
+                .map(|pattern| (pattern, "[CURRENT_EXE]".to_string())),
+        );
+
         Self {
             temp_dir,
             home_dir,
@@ -135,6 +142,18 @@ impl TestContext {
         command
     }
 
+    pub fn install(&self) -> Command {
+        let mut command = self.command();
+        command.arg("install");
+        command
+    }
+
+    pub fn uninstall(&self) -> Command {
+        let mut command = self.command();
+        command.arg("uninstall");
+        command
+    }
+
     /// Standard snapshot filters _plus_ those for this test context.
     pub fn filters(&self) -> Vec<(&str, &str)> {
         // Put test context snapshots before the default filters
@@ -194,7 +213,7 @@ pub const INSTA_FILTERS: &[(&str, &str)] = &[
     (r"(\s|\()(\d+\.)?\d+([KM]i)?B", "$1[SIZE]"),
     // Rewrite Windows output to Unix output
     (r"\\([\w\d]|\.\.)", "/$1"),
-    (r"pre-commit-rs.exe", "pre-commit-rs"),
+    (r"pre-commit.exe", "pre-commit"),
     // The exact message is host language dependent
     (
         r"Caused by: .* \(os error 2\)",
diff --git a/tests/install.rs b/tests/install.rs
new file mode 100644
index 0000000..3a34139
--- /dev/null
+++ b/tests/install.rs
@@ -0,0 +1,157 @@
+use crate::common::{cmd_snapshot, TestContext};
+use assert_fs::fixture::{FileWriteStr, PathChild};
+use insta::assert_snapshot;
+
+mod common;
+
+#[test]
+fn install() -> anyhow::Result<()> {
+    let context = TestContext::new();
+
+    context.init_project();
+
+    // Install `pre-commit` hook.
+    cmd_snapshot!(context.filters(), context.install(), @r#"
+    success: true
+    exit_code: 0
+    ----- stdout -----
+    pre-commit installed at .git/hooks/pre-commit
+
+    ----- stderr -----
+    "#);
+
+    insta::with_settings!(
+        { filters => context.filters() },
+        {
+            assert_snapshot!(context.read(".git/hooks/pre-commit"), @r##"
+            #!/usr/bin/env bash
+            # File generated by pre-commit-rs: https://github.com/j178/pre-commit-rs
+            # ID: 182c10f181da4464a3eec51b83331688
+
+            ARGS=(hook-impl '--config=' '--hook-type=pre-commit')
+
+            HERE="$(cd "$(dirname "$0")" && pwd)"
+            ARGS+=(--hook-dir "$HERE" -- "$@")
+            PRE_COMMIT="[CURRENT_EXE]"
+
+            exec "$PRE_COMMIT" "${ARGS[@]}"
+
+            "##);
+        }
+    );
+
+    // Install `pre-commit` and `post-commit` hook.
+    context
+        .workdir()
+        .child(".git/hooks/pre-commit")
+        .write_str("#!/bin/sh\necho 'pre-commit'\n")?;
+
+    cmd_snapshot!(context.filters(), context.install().arg("--hook-types").arg("pre-commit").arg("--hook-types").arg("post-commit"), @r#"
+    success: true
+    exit_code: 0
+    ----- stdout -----
+    Hook already exists at .git/hooks/pre-commit, move it to .git/hooks/pre-commit.legacy.
+    pre-commit installed at .git/hooks/pre-commit
+    pre-commit installed at .git/hooks/post-commit
+
+    ----- stderr -----
+    "#);
+    insta::with_settings!(
+        { filters => context.filters() },
+        {
+            assert_snapshot!(context.read(".git/hooks/pre-commit"), @r##"
+            #!/usr/bin/env bash
+            # File generated by pre-commit-rs: https://github.com/j178/pre-commit-rs
+            # ID: 182c10f181da4464a3eec51b83331688
+
+            ARGS=(hook-impl '--config=' '--hook-type=pre-commit')
+
+            HERE="$(cd "$(dirname "$0")" && pwd)"
+            ARGS+=(--hook-dir "$HERE" -- "$@")
+            PRE_COMMIT="[CURRENT_EXE]"
+
+            exec "$PRE_COMMIT" "${ARGS[@]}"
+
+            "##);
+        }
+    );
+
+    assert_snapshot!(context.read(".git/hooks/pre-commit.legacy"), @r##"
+    #!/bin/sh
+    echo 'pre-commit'
+    "##);
+
+    insta::with_settings!(
+        { filters => context.filters() },
+        {
+            assert_snapshot!(context.read(".git/hooks/post-commit"), @r##"
+            #!/usr/bin/env bash
+            # File generated by pre-commit-rs: https://github.com/j178/pre-commit-rs
+            # ID: 182c10f181da4464a3eec51b83331688
+
+            ARGS=(hook-impl '--config=' '--hook-type=post-commit')
+
+            HERE="$(cd "$(dirname "$0")" && pwd)"
+            ARGS+=(--hook-dir "$HERE" -- "$@")
+            PRE_COMMIT="[CURRENT_EXE]"
+
+            exec "$PRE_COMMIT" "${ARGS[@]}"
+
+            "##);
+        }
+    );
+
+    // Overwrite existing hooks.
+    cmd_snapshot!(context.filters(), context.install().arg("--hook-types").arg("pre-commit post-commit").arg("--overwrite"), @r#"
+    success: true
+    exit_code: 0
+    ----- stdout -----
+    Overwriting existing hook at .git/hooks/pre-commit
+    pre-commit installed at .git/hooks/pre-commit
+    Overwriting existing hook at .git/hooks/post-commit
+    pre-commit installed at .git/hooks/post-commit
+
+    ----- stderr -----
+    "#);
+
+    insta::with_settings!(
+        { filters => context.filters() },
+        {
+            assert_snapshot!(context.read(".git/hooks/pre-commit"), @r##"
+            #!/usr/bin/env bash
+            # File generated by pre-commit-rs: https://github.com/j178/pre-commit-rs
+            # ID: 182c10f181da4464a3eec51b83331688
+
+            ARGS=(hook-impl '--config=' '--hook-type=pre-commit')
+
+            HERE="$(cd "$(dirname "$0")" && pwd)"
+            ARGS+=(--hook-dir "$HERE" -- "$@")
+            PRE_COMMIT="[CURRENT_EXE]"
+
+            exec "$PRE_COMMIT" "${ARGS[@]}"
+
+            "##);
+        }
+    );
+    insta::with_settings!(
+        { filters => context.filters() },
+        {
+            assert_snapshot!(context.read(".git/hooks/post-commit"), @r##"
+            #!/usr/bin/env bash
+            # File generated by pre-commit-rs: https://github.com/j178/pre-commit-rs
+            # ID: 182c10f181da4464a3eec51b83331688
+
+            ARGS=(hook-impl '--config=' '--hook-type=post-commit')
+
+            HERE="$(cd "$(dirname "$0")" && pwd)"
+            ARGS+=(--hook-dir "$HERE" -- "$@")
+            PRE_COMMIT="[CURRENT_EXE]"
+
+            exec "$PRE_COMMIT" "${ARGS[@]}"
+
+            "##);
+        }
+    );
+
+    Ok(())
+}