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
//! What:
//! The single `post-commit` run after a landing: its author environment, the hook lock around it,
//! and the real Git hook runner with inherited standard streams.
//! Why:
//! The incumbent's `commit-landing-post-commit-hook.ts` runs it outside both landing locks,
//! under the hook lock unless `hooks.concurrentCommits` is `true` or an outer preparation lease is inherited.
//! Its exit status is ignored, as native Git ignores it, and the user sees the hook's output on the terminal.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! await runPostCommitHook({ gitPath, cwd, globalArgs, realIndexPath, landedOid, plan });
//! ```
use super::diagnostic_log::debug;
use super::forwarding::git_command;
use super::hook_dispatch_plan::HookDispatchPlan;
use super::owner_lock::{DEFAULT_POLL_DELAY, OwnerLock, SilentWait, acquire_owner_lock};
use super::transaction_git::{GitContext, GitRequest, run_git};
use std::ffi::OsString;
use std::path::Path;
use std::process::{Command, Stdio};
/// What:
/// A failure of the post-commit run: the author lookup, the hook lock, or the hook's start.
/// Why:
/// The caller reports the message and exits 1, as the incumbent reports a thrown error after a landing.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// throw new CommitTransactionGitError(message);
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PostCommitHookError(pub String);
/// What:
/// Whether `post-commit` ran, and the status its hook returned when it did.
/// Why:
/// A disabled event runs nothing, and a non-zero status is recorded but never fails the landed commit.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type PostCommitHookOutcome = 'disabled' | { exitCode: number };
/// ```
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PostCommitHookOutcome {
/// `hook.post-commit.enabled=false` disabled the event; nothing ran.
Disabled,
/// The hook runner's status, which the caller ignores.
Ran(i32),
}
/// What:
/// The inputs of one post-commit run.
/// Why:
/// Named fields keep the paths apart, and the plan carries the disabled events and the hook lock.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// { gitPath, cwd, globalArgs, realIndexPath, landedOid, plan }
/// ```
#[derive(Clone, Copy, Debug)]
pub struct PostCommitHookRequest<'a> {
/// Real Git and the caller's global options.
pub context: &'a GitContext,
/// Owning worktree root, where the hook runs.
pub worktree: &'a Path,
/// Real index native Git names in `GIT_INDEX_FILE`.
pub real_index: &'a Path,
/// Landed commit whose author the hook sees.
pub landed_oid: &'a str,
/// Dispatch plan holding the disabled events and the hook lock.
pub plan: &'a HookDispatchPlan,
}
/// What:
/// Run `post-commit` once for a landed commit.
/// Why:
/// The order matches the incumbent: disabled check, author lookup, hook lock, hook run, lock release.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// if (plan.disabledEvents.includes('post-commit')) return; await using lock = plan.skipHookLock ? undefined : await acquireOwnerLock(...); await runTransactionGit(...);
/// ```
pub fn run_post_commit_hook(
request: PostCommitHookRequest,
) -> Result<PostCommitHookOutcome, PostCommitHookError> {
if request.plan.disabled_events.iter().any(|name| {
return name.as_str() == "post-commit";
}) {
debug(
"runPostCommitHook",
"post-commit disabled by hook.post-commit.enabled=false",
);
return Ok(PostCommitHookOutcome::Disabled);
}
let mut environment: Vec<(OsString, OsString)> =
read_author_environment(request.context, request.worktree, request.landed_oid)?;
environment.push((
OsString::from("GIT_INDEX_FILE"),
request.real_index.as_os_str().to_os_string(),
));
environment.push((OsString::from("GIT_EDITOR"), OsString::from(":")));
// Held until the end of this function, so an early return releases it through `Drop`.
let lock: Option<OwnerLock> = if request.plan.skip_hook_lock {
None
} else {
Some(acquire_hook_lock(request.plan.hook_lock_path.as_str())?)
};
let status: i32 = run_hook(request.context, request.worktree, environment.as_slice())?;
if let Some(held) = lock {
held.release()
.map_err(|error| return PostCommitHookError(error.to_string()))?;
}
return Ok(PostCommitHookOutcome::Ran(status));
}
/// What:
/// Take the hook lock for the post-commit run, waiting silently while a live owner holds it.
/// Why:
/// The incumbent's `acquireOwnerLock` on `plan.hookLockPath`, shared with the preparation dispatchers.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await acquireOwnerLock({ lockDirectory: plan.hookLockPath });
/// ```
fn acquire_hook_lock(lock_path: &str) -> Result<OwnerLock, PostCommitHookError> {
let directory: &Path = Path::new(lock_path);
// The incumbent creates the lock's parents recursively; the native owner lock creates one level only.
if let Some(parent) = directory.parent() {
std::fs::create_dir_all(parent).map_err(|error| {
return PostCommitHookError(format!("creating {} failed: {error}", parent.display()));
})?;
}
return acquire_owner_lock(directory, DEFAULT_POLL_DELAY, &mut SilentWait)
.map_err(|error| return PostCommitHookError(error.to_string()));
}
/// What:
/// Read the landed commit's author as `GIT_AUTHOR_*` variables, through a Git start without the global options.
/// Why:
/// The incumbent's `authorEnvironment` runs `show` with the bare command; a refusal fails the run before the hook starts.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await runTransactionGit({ gitPath, cwd, args: ['show', '--no-patch', '--date=raw', '--format=%an%x00%ae%x00%ad', landedOid] });
/// ```
fn read_author_environment(
context: &GitContext,
worktree: &Path,
landed_oid: &str,
) -> Result<Vec<(OsString, OsString)>, PostCommitHookError> {
let request: GitRequest = GitRequest {
without_prefix: true,
..GitRequest::new(
worktree,
&[
"show",
"--no-patch",
"--date=raw",
"--format=%an%x00%ae%x00%ad",
landed_oid,
],
)
};
let output = run_git(context, &request).map_err(|error| {
return PostCommitHookError(format!("git show could not start: {error}"));
})?;
if !output.succeeded() {
return Err(PostCommitHookError(output.error_text()));
}
let text: String = String::from_utf8(output.stdout).map_err(|error| {
return PostCommitHookError(format!(
"the landed commit's author fields are not UTF-8: {error}"
));
})?;
return Ok(author_environment(text.as_str())
.into_iter()
.map(|(name, value)| {
return (OsString::from(name), OsString::from(value));
})
.collect());
}
/// What:
/// Start the real Git hook runner for `post-commit` with inherited standard streams, and return its status.
/// Why:
/// The user sees the hook's output on the terminal, as the incumbent's `stdio: 'inherit'` gives it;
/// a missing status (a signal) is 1, which the caller ignores.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await runTransactionGit({ gitPath, cwd, args: [...globalArgs, 'hook', 'run', '--ignore-missing', 'post-commit'], environment, stdio: 'inherit', allowFailure: true });
/// ```
fn run_hook(
context: &GitContext,
worktree: &Path,
environment: &[(OsString, OsString)],
) -> Result<i32, PostCommitHookError> {
let mut arguments: Vec<OsString> = context.global_prefix.clone();
arguments.extend(
["hook", "run", "--ignore-missing", "post-commit"]
.into_iter()
.map(OsString::from),
);
let mut command: Command = git_command(
context.real_git.as_path(),
arguments.as_slice(),
context.overlay.as_slice(),
);
command
.current_dir(worktree)
.envs(environment.iter().cloned())
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
let finished = command.status().map_err(|error| {
return PostCommitHookError(format!(
"{} could not start: {error}",
context.real_git.display()
));
})?;
return Ok(finished.code().unwrap_or(1));
}
/// What:
/// The environment native `git commit` gives the hook, from the landed commit's `%an`, `%ae` and raw `%ad`.
/// Why:
/// Hooks read `GIT_AUTHOR_*`; the incumbent takes the NUL-separated fields and drops one trailing newline.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const [name = '', email = '', date = ''] = output.replace(/\n$/, '').split('\0');
/// ```
pub fn author_environment(output: &str) -> Vec<(String, String)> {
let trimmed: &str = output.strip_suffix('\n').unwrap_or(output);
let mut fields = trimmed.split('\0');
let name: &str = fields.next().unwrap_or("");
let email: &str = fields.next().unwrap_or("");
let date: &str = fields.next().unwrap_or("");
return vec![
(String::from("GIT_AUTHOR_NAME"), String::from(name)),
(String::from("GIT_AUTHOR_EMAIL"), String::from(email)),
(String::from("GIT_AUTHOR_DATE"), format!("@{date}")),
];
}
/// Post-commit controls stay out of the release executable.
#[cfg(test)]
#[path = "post_commit_hook_tests.rs"]
mod tests;