Skip to content

Add optional git pre-commit hooks for cljfmt & clj-kondo #32

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,10 @@ The most common environment variables to set would include:
- If a server fails, it is auto-detected and the work of the server is redistributed to the remaining servers in the cluster
- If a server joins, work is redistributed to include the new server

## Development

### Git hooks

- Run `bb git-hooks install`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what I'd need to do for this to work going forwards, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, or perhaps more properly bb run git-hooks install but this should work too.

- This will set up a pre-commit git hook that checks your staged changes
with cljfmt and clj-kondo.
6 changes: 6 additions & 0 deletions bb.edn
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{:paths ["bb"]
:pods {clj-kondo/clj-kondo {:version "2023.12.15"}}
:deps {dev.weavejester/cljfmt {:git/url "https://github.com/cap10morgan/cljfmt.git"
:git/sha "acc6a7d1e5a7e3391419ac731bf8a2774b8c4a13"}}
:tasks {git-hooks {:requires ([git-hooks :as gh])
:task (apply gh/hook *command-line-args*)}}}
98 changes: 98 additions & 0 deletions bb/git_hooks.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
(ns git-hooks
(:require [babashka.fs :as fs]
[clojure.edn :as edn]
[clojure.string :as str]
[lib.clj-kondo :as clj-kondo]
[lib.cljfmt :as cljfmt]
[lib.git :as git])
(:import (java.util Date)))

;; originally inspired by https://blaster.ai/blog/posts/manage-git-hooks-w-babashka.html

;; installation

(defn hook-script
[hook]
(format "#!/bin/sh
# Installed by babashka task on %s

bb git-hooks %s" (Date.) hook))

(defn spit-hook
[hook]
(println "Installing git hook:" hook)
(let [file (str ".git/hooks/" hook)]
(spit file (hook-script hook))
(fs/set-posix-file-permissions file "rwx------")
(assert (fs/executable? file))))

(defmulti install-hook (fn [& args] (-> args first keyword)))

(defmethod install-hook :pre-commit
[& _]
(spit-hook "pre-commit"))

(defmethod install-hook :default
[& args]
(println "Unknown git hook:" (first args)))

;; checks

(defn- check-enabled?
[check]
(let [skip-checks (System/getenv "SKIP_CHECKS")
skip-checks-coll (when skip-checks
(edn/read-string
(if (str/starts-with? skip-checks "[")
skip-checks
(str "[" skip-checks "]"))))
disabled-checks (into #{} (map keyword) skip-checks-coll)]
(not (contains? disabled-checks check))))

(defmulti check (fn [& args] (-> args first keyword)))

(defmethod check :cljfmt
[_ staging-dir]
(let [bad-files (cljfmt/check staging-dir)]
(when (and (check-enabled? :cljfmt) (seq bad-files))
(println)
(println (let [bfc (count bad-files)]
(str bfc " " (if (= 1 bfc) "file is" "files are")
" formatted incorrectly:\n"
(str/join "\n" bad-files)
"\n")))
(println "Run: cljfmt fix" (str/join " " bad-files))
(println " git add" (str/join " " bad-files))
(println)
(System/exit 1))))

(defmethod check :clj-kondo
[_ staging-dir]
(let [bad-files (clj-kondo/lint staging-dir)]
(when (and (check-enabled? :clj-kondo) (seq bad-files))
(println)
(println "Fix errors and run: git add" (str/join " " bad-files))
(System/exit 2))))

;; hooks

(defmulti hook (fn [& args] (-> args first keyword)))

(defmethod hook :install
[& _]
(install-hook :pre-commit))

(defmethod hook :pre-commit
[& _]
(println "Running pre-commit hook")
(when-let [files (git/changed-files)]
(println "Found" (count files) "changed files\n")
(let [staging-dir (git/staged-contents files)]
(check :cljfmt staging-dir)
(check :clj-kondo staging-dir))
(println)
(println "🤘 Commit looks good! 🤘")))

(defmethod hook :default
[& args]
(println "Unknown git hook:" (first args)))
35 changes: 35 additions & 0 deletions bb/lib/clj_kondo.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
(ns lib.clj-kondo
(:require [clojure.java.io :as io]
[clojure.string :as str]
[lib.path :as path]
[pod.borkdude.clj-kondo :as clj-kondo]))

(def linted-paths
["src" "test" "bb" "build.clj"])

(defn lint
"Runs clj-kondo --lint on all files (recursively) in dir. Returns a collection
of any files that failed the check."
[dir]
(let [lint-paths (map #(str dir "/" %)
(filter #(->> % (io/file dir) .exists) linted-paths))
result (clj-kondo/run! {:lint lint-paths})
{{:keys [error warning]} :summary} result]
(if (and (zero? error) (zero? warning))
[]
(let [->proj-path (partial path/tmp->project-rel
(if (str/ends-with? dir "/") dir (str dir "/")))
bad-files (->> result :findings
(map #(update % :filename ->proj-path)))]
(println "clj-kondo lint:")
(doseq [{:keys [filename level message row end-row col end-col] :as _bf}
bad-files]
(let [row-desc (if (not= row end-row)
(str row "-" end-row)
(str row))
col-desc (if (not= col end-col)
(str col "-" end-col)
(str col))]
(println (str filename ":" row-desc ":" col-desc ":" level)
"-" message)))
(map :filename bad-files)))))
20 changes: 20 additions & 0 deletions bb/lib/cljfmt.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
(ns lib.cljfmt
(:require [cljfmt.lib :as fmt]
[clojure.string :as str]
[lib.path :as path]))

(defn check
"Runs cljfmt check on all files (recursively) in dir. Returns a collection of
any files that failed the check."
[dir]
(let [cljfmt-opts {:paths [dir], :diff? false}
{:keys [counts incorrect error] :as _result} (fmt/check cljfmt-opts)]
#_(println "cljfmt results:" (pr-str result))
(if (and (zero? (:incorrect counts)) (zero? (:error counts)))
[]
(let [->proj-path (partial path/tmp->project-rel
(if (str/ends-with? dir "/")
dir
(str dir "/")))]
(concat (map (fn [[file]] (->proj-path file)) incorrect)
(map (fn [[file]] (->proj-path file)) error))))))
28 changes: 28 additions & 0 deletions bb/lib/git.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
(ns lib.git
(:require [babashka.fs :as fs]
[clojure.java.shell :refer [sh]]
[clojure.string :as str]))

(defn changed-files
"Returns the filenames of all changed files currently staged in git.
NB: This is not the same as what will be committed. Git allows parts of files
to be staged while other parts are left un-staged. So the content of these files
in your local working copy won't always be the same as what would be committed
from that index. You probably want to pass the return value of this fn to the
`staged-contents` fn before running any hooks."
[]
(->> (sh "git" "diff" "--cached" "--name-only" "--diff-filter=ACM")
:out
str/split-lines
(filter seq)
seq))

(defn staged-contents
"Returns the path to a temp dir with the staged contents of the files
argument. This is necessary because git allows partial staging of files."
[files]
(let [tmp-dir (str (fs/create-temp-dir))]
#_(println "staging dir:" tmp-dir)
(sh "git" "checkout-index" (str "--prefix=" tmp-dir "/") "--stdin"
:in (str/join "\n" files))
tmp-dir))
12 changes: 12 additions & 0 deletions bb/lib/path.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
(ns lib.path
(:require [clojure.string :as str]))

(defn tmp->project-rel
"Takes a temp root dir and full temp path and removes the temp root prefix so
that the returned path is relative to the project."
[tmp-root tmp-path]
(-> tmp-path
str/join
(str/replace "../" "")
(str/replace #"^[^/]" "/")
(str/replace-first tmp-root "")))