Skip to content

Commit 7aeb63d

Browse files
authored
Merge pull request #2 from epage/hooks
feat(hooks): post-rewrite and reference transaction support
2 parents f1694b3 + 147bb8c commit 7aeb63d

File tree

4 files changed

+285
-0
lines changed

4 files changed

+285
-0
lines changed

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ pre-release-replacements = [
3333
git2 = { version = "0.14", default-features = false }
3434
log = "0.4"
3535
itertools = "0.10"
36+
which = "4"
3637

3738
[dev-dependencies]
3839
git-fixture = { version = "0.2" }

src/hooks.rs

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
#[derive(Clone, Debug)]
2+
pub struct Hooks {
3+
root: std::path::PathBuf,
4+
}
5+
6+
impl Hooks {
7+
pub fn new(hook_root: impl Into<std::path::PathBuf>) -> Self {
8+
Self {
9+
root: hook_root.into(),
10+
}
11+
}
12+
13+
pub fn with_repo(repo: &git2::Repository) -> Result<Self, git2::Error> {
14+
let config = repo.config()?;
15+
let root = config
16+
.get_path("core.hooksPath")
17+
.unwrap_or_else(|_| repo.path().join("hooks"));
18+
Ok(Self::new(root))
19+
}
20+
21+
pub fn root(&self) -> &std::path::Path {
22+
&self.root
23+
}
24+
25+
pub fn find_hook(&self, _repo: &git2::Repository, name: &str) -> Option<std::path::PathBuf> {
26+
let mut hook_path = self.root().join(name);
27+
if is_executable(&hook_path) {
28+
return Some(hook_path);
29+
}
30+
31+
if !std::env::consts::EXE_SUFFIX.is_empty() {
32+
hook_path.set_extension(std::env::consts::EXE_SUFFIX);
33+
if is_executable(&hook_path) {
34+
return Some(hook_path);
35+
}
36+
}
37+
38+
// Technically, we should check `advice.ignoredHook` and warn users if the hook is present
39+
// but not executable. Supporting this in the future is why we accept `repo`.
40+
41+
None
42+
}
43+
44+
pub fn run_hook(
45+
&self,
46+
repo: &git2::Repository,
47+
name: &str,
48+
args: &[&str],
49+
stdin: Option<&[u8]>,
50+
env: &[(&str, &str)],
51+
) -> Result<i32, std::io::Error> {
52+
let hook_path = if let Some(hook_path) = self.find_hook(repo, name) {
53+
hook_path
54+
} else {
55+
return Ok(0);
56+
};
57+
let bin_name = hook_path
58+
.file_name()
59+
.expect("find_hook always returns a bin name")
60+
.to_str()
61+
.expect("find_hook always returns a utf-8 bin name");
62+
63+
let path = {
64+
let mut path_components: Vec<std::path::PathBuf> =
65+
vec![std::fs::canonicalize(self.root())?];
66+
if let Some(path) = std::env::var_os(std::ffi::OsStr::new("PATH")) {
67+
path_components.extend(std::env::split_paths(&path));
68+
}
69+
std::env::join_paths(path_components)
70+
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?
71+
};
72+
73+
let sh_path = crate::utils::git_sh().ok_or_else(|| {
74+
std::io::Error::new(std::io::ErrorKind::NotFound, "No `sh` for running hooks")
75+
})?;
76+
77+
// From `githooks(5)`:
78+
// > Before Git invokes a hook, it changes its working directory to either $GIT_DIR in a bare
79+
// > repository or the root of the working tree in a non-bare repository. An exception are
80+
// > hooks triggered during a push (pre-receive, update, post-receive, post-update,
81+
// > push-to-checkout) which are always executed in $GIT_DIR.
82+
let cwd = if PUSH_HOOKS.contains(&name) {
83+
repo.path()
84+
} else {
85+
repo.workdir().unwrap_or_else(|| repo.path())
86+
};
87+
88+
let mut cmd = std::process::Command::new(sh_path);
89+
cmd.arg("-c")
90+
.arg(format!("{} \"$@\"", bin_name))
91+
.arg(bin_name) // "$@" expands "$1" "$2" "$3" ... but we also must specify $0.
92+
.args(args)
93+
.env("PATH", path)
94+
.current_dir(cwd)
95+
// Technically, git maps stdout to stderr when running hooks
96+
.stdin(std::process::Stdio::piped());
97+
for (key, value) in env.iter().copied() {
98+
cmd.env(key, value);
99+
}
100+
let mut process = cmd.spawn()?;
101+
if let Some(stdin) = stdin {
102+
use std::io::Write;
103+
104+
process.stdin.as_mut().unwrap().write_all(stdin)?;
105+
}
106+
let exit = process.wait()?;
107+
108+
const SIGNAL_EXIT_CODE: i32 = 1;
109+
Ok(exit.code().unwrap_or(SIGNAL_EXIT_CODE))
110+
}
111+
112+
/// Run `post-rewrite` hook as if called by `git rebase`
113+
///
114+
/// The hook should be run after any automatic note copying (see "notes.rewrite.<command>" in
115+
/// git-config(1)) has happened, and thus has access to these notes.
116+
///
117+
/// **changed_shas (old, new):**
118+
/// - For the squash and fixup operation, all commits that were squashed are listed as being rewritten to the squashed commit. This means
119+
/// that there will be several lines sharing the same new-sha1.
120+
/// - The commits are must be listed in the order that they were processed by rebase.
121+
/// - `git` doesn't include entries for dropped commits
122+
pub fn run_post_rewrite_rebase(
123+
&self,
124+
repo: &git2::Repository,
125+
changed_oids: &[(git2::Oid, git2::Oid)],
126+
) -> Result<(), std::io::Error> {
127+
let name = "post-rewrite";
128+
let command = "rebase";
129+
let args = [command];
130+
let mut stdin = String::new();
131+
for (old_oid, new_oid) in changed_oids {
132+
use std::fmt::Write;
133+
writeln!(stdin, "{} {}", old_oid, new_oid).expect("Always writeable");
134+
}
135+
136+
let code = self.run_hook(repo, name, &args, Some(stdin.as_bytes()), &[])?;
137+
log::trace!("Hook `{}` failed with code {}", name, code);
138+
139+
Ok(())
140+
}
141+
142+
/// Run `reference-transaction` hook to signal that all reference updates have been queued to the transaction.
143+
///
144+
/// **changed_refs (old, new, name):**
145+
/// - `name` is the full name of the ref
146+
/// - `old` is zeroed out when force updating the reference regardless of its current value or
147+
/// when the reference is to be created anew
148+
///
149+
/// On success, call either
150+
/// - `run_reference_transaction_committed`
151+
/// - `run_reference_transaction_aborted`.
152+
///
153+
/// On failure, the transaction is considered aborted
154+
pub fn run_reference_transaction_prepare(
155+
&self,
156+
repo: &git2::Repository,
157+
changed_refs: &[(git2::Oid, git2::Oid, &str)],
158+
) -> Result<(), std::io::Error> {
159+
let name = "reference-transaction";
160+
let state = "prepare";
161+
let args = [state];
162+
let mut stdin = String::new();
163+
for (old_oid, new_oid, ref_name) in changed_refs {
164+
use std::fmt::Write;
165+
writeln!(stdin, "{} {} {}", old_oid, new_oid, ref_name).expect("Always writeable");
166+
}
167+
168+
let code = self.run_hook(repo, name, &args, Some(stdin.as_bytes()), &[])?;
169+
if code == 0 {
170+
Ok(())
171+
} else {
172+
log::trace!("Hook `{}` failed with code {}", name, code);
173+
Err(std::io::Error::new(
174+
std::io::ErrorKind::Interrupted,
175+
format!("`{}` hook failed with code {}", name, code),
176+
))
177+
}
178+
}
179+
180+
/// Run `reference-transaction` hook to signal that all reference updates have been applied
181+
///
182+
/// **changed_refs (old, new, name):**
183+
/// - `name` is the full name of the ref
184+
/// - `old` is zeroed out when force updating the reference regardless of its current value or
185+
/// when the reference is to be created anew
186+
pub fn run_reference_transaction_committed(
187+
&self,
188+
repo: &git2::Repository,
189+
changed_refs: &[(git2::Oid, git2::Oid, &str)],
190+
) -> Result<(), std::io::Error> {
191+
let name = "reference-transaction";
192+
let state = "committed";
193+
let args = [state];
194+
let mut stdin = String::new();
195+
for (old_oid, new_oid, ref_name) in changed_refs {
196+
use std::fmt::Write;
197+
writeln!(stdin, "{} {} {}", old_oid, new_oid, ref_name).expect("Always writeable");
198+
}
199+
200+
let code = self.run_hook(repo, name, &args, Some(stdin.as_bytes()), &[])?;
201+
log::trace!("Hook `{}` failed with code {}", name, code);
202+
203+
Ok(())
204+
}
205+
206+
/// Run `reference-transaction` hook to signal that no changes have been made
207+
///
208+
/// **changed_refs (old, new, name):**
209+
/// - `name` is the full name of the ref
210+
/// - `old` is zeroed out when force updating the reference regardless of its current value or
211+
/// when the reference is to be created anew
212+
pub fn run_reference_transaction_aborted(
213+
&self,
214+
repo: &git2::Repository,
215+
changed_refs: &[(git2::Oid, git2::Oid, &str)],
216+
) -> Result<(), std::io::Error> {
217+
let name = "reference-transaction";
218+
let state = "aborted";
219+
let args = [state];
220+
let mut stdin = String::new();
221+
for (old_oid, new_oid, ref_name) in changed_refs {
222+
use std::fmt::Write;
223+
writeln!(stdin, "{} {} {}", old_oid, new_oid, ref_name).expect("Always writeable");
224+
}
225+
226+
let code = self.run_hook(repo, name, &args, Some(stdin.as_bytes()), &[])?;
227+
log::trace!("Hook `{}` failed with code {}", name, code);
228+
229+
Ok(())
230+
}
231+
}
232+
233+
const PUSH_HOOKS: &[&str] = &[
234+
"pre-receive",
235+
"update",
236+
"post-receive",
237+
"post-update",
238+
"push-to-checkout",
239+
];
240+
241+
#[cfg(unix)]
242+
fn is_executable(path: &std::path::Path) -> bool {
243+
use std::os::unix::fs::PermissionsExt;
244+
245+
let metadata = match path.metadata() {
246+
Ok(metadata) => metadata,
247+
Err(_) => return false,
248+
};
249+
let permissions = metadata.permissions();
250+
metadata.is_file() && permissions.mode() & 0o111 != 0
251+
}
252+
253+
#[cfg(not(unix))]
254+
fn is_executable(path: &std::path::Path) -> bool {
255+
path.is_file()
256+
}

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1+
pub mod hooks;
12
pub mod ops;
3+
pub mod utils;

src/utils.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/// Path to a shell suitable for running hooks.
2+
pub fn git_sh() -> Option<std::path::PathBuf> {
3+
let exe_name = if cfg!(target_os = "windows") {
4+
"bash.exe"
5+
} else {
6+
"sh"
7+
};
8+
9+
if cfg!(target_os = "windows") {
10+
// Prefer git-bash since that is how git will normally be running the hooks
11+
if let Some(path) = find_git_bash() {
12+
return Some(path);
13+
}
14+
}
15+
16+
which::which(exe_name).ok()
17+
}
18+
19+
fn find_git_bash() -> Option<std::path::PathBuf> {
20+
// Git is typically installed at C:\Program Files\Git\cmd\git.exe with the cmd\ directory
21+
// in the path, however git-bash is usually not in PATH and is in bin\ directory:
22+
let git_path = which::which("git.exe").ok()?;
23+
let git_dir = git_path.parent()?.parent()?;
24+
let git_bash = git_dir.join("bin").join("bash.exe");
25+
git_bash.is_file().then(|| git_bash)
26+
}

0 commit comments

Comments
 (0)