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
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
//! What:
//! The real index a landing installs, computed against the real index as it is at landing,
//! never against the invocation-time copy.
//! Why:
//! A landing that copied the invocation-time index would erase another process's staging
//! and stage a revert of content that already landed. Index mode takes the exact private index only
//! when nothing was restaged; otherwise it merges the landed entries path by path, and explicit-path
//! mode resets only the committed paths and the guarded paths still holding their invocation entry
//! (`src/policy-engine/commit-landing-index.ts`).
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! await computeLandingPostIndex({ gitPath, cwd, mode, preLandingIndexPath, capturedIndexPath, landedIndexPath, postIndexPath, ... });
//! ```
/// Index copy with the timestamps Git reads.
use super::commit_index::copy_index_with_timestamps;
/// Snapshot comparison: exact bytes, refusing symbolic links.
use super::recovery_files::files_equal;
/// Warning text to the diagnostic log.
use super::diagnostic_log::warn;
/// Commit selection mode of the transaction.
use super::transaction_journal::TransactionMode;
/// Running real Git, checked and unchecked.
use super::transaction_git::{GitContext, GitFailure, GitOutput, GitRequest, run_git, run_git_checked};
/// Native `HashMap` of stage records and the ordered path list.
use std::collections::HashMap;
/// `OsString` is owned operating-system text; `Path`/`PathBuf` are filesystem paths.
use std::ffi::OsString;
use std::path::Path;
/// Module tag for diagnostics.
const TAG: &str = "computeLandingPostIndex";
/// What:
/// The inputs of one post-index computation.
/// Why:
/// The index artifacts and the landed tree travel together; a struct keeps the call readable.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// { mode, preLandingIndexPath, capturedIndexPath, landedIndexPath, postIndexPath, landedTreeOid, baseRevision, committedPaths, guardedPaths, exactPrivateIndex }
/// ```
pub struct PostIndexInput<'input> {
/// Commit selection mode.
pub mode: TransactionMode,
/// Exact copy of the real index taken at landing.
pub pre_landing_index: &'input Path,
/// Invocation-time index copy.
pub captured_index: &'input Path,
/// Private index the landed commit was made from.
pub landed_index: &'input Path,
/// Post-index written by this computation.
pub post_index: &'input Path,
/// Tree the landing installs.
pub landed_tree: &'input str,
/// Preparation base commit, or the empty tree when unborn.
pub base_revision: &'input str,
/// Paths an explicit-path commit carries, including policy-added paths.
pub committed_paths: &'input [String],
/// Paths a commit hook changed.
pub guarded_paths: &'input [String],
/// Whether `landed_index` is exactly the index the commit was prepared from.
pub exact_private_index: bool,
}
/// What:
/// Runs real Git with the literal-pathspec override that keeps `:(top,literal)` paths concrete.
/// Why:
/// The caller may disable pathspec magic, and the landing names exact paths.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// environment: { GIT_LITERAL_PATHSPECS: '0' }
/// ```
fn literal_pathspecs() -> Vec<(OsString, OsString)> {
return vec![(OsString::from("GIT_LITERAL_PATHSPECS"), OsString::from("0"))];
}
/// What:
/// Decodes Git output strictly as UTF-8.
/// Why:
/// A path or subject that is not UTF-8 must fail the landing, not be replaced.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// new TextDecoder('utf-8', { fatal: true }).decode(bytes)
/// ```
fn decode(bytes: &[u8]) -> Result<String, GitFailure> {
return String::from_utf8(bytes.to_vec())
.map_err(|error| { return GitFailure(format!("Git output is not UTF-8: {error}")); });
}
/// What:
/// Splits NUL-terminated records and drops empty ones.
/// Why:
/// Every `-z` listing ends each record with NUL.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// decode(bytes).split('\0').filter(nonempty)
/// ```
fn records(bytes: &[u8]) -> Result<Vec<String>, GitFailure> {
return Ok(decode(bytes)?
.split('\0')
.filter(|record| { return !record.is_empty(); })
.map(str::to_string)
.collect());
}
/// What:
/// Lists the stage records of concrete paths in one index, grouped by path.
/// Why:
/// Comparing the stages of a path decides whether anyone restaged it since invocation.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// listStages({ gitPath, cwd, indexPath, paths });
/// ```
fn list_stages(
context: &GitContext,
cwd: &Path,
index: &Path,
paths: &[String],
) -> Result<HashMap<String, Vec<String>>, GitFailure> {
let mut grouped: HashMap<String, Vec<String>> = HashMap::new();
if paths.is_empty() {
return Ok(grouped);
}
let mut arguments: Vec<String> = vec!["ls-files".into(), "--stage".into(), "-z".into(), "--full-name".into(), "--".into()];
arguments.extend(paths.iter().map(|path| format!(":(top,literal){path}")));
let mut request = GitRequest::new(cwd, &arguments);
request.index = Some(index.to_path_buf());
request.environment = literal_pathspecs();
let output: GitOutput = run_git_checked(context, &request)?;
for record in records(&output.stdout)? {
let Some((_, path)) = record.split_once('\t') else {
return Err(GitFailure(format!("ls-files record has no path: {record}")));
};
grouped.entry(path.to_string()).or_default().push(record.clone());
}
return Ok(grouped);
}
/// What:
/// Lists the paths two trees differ in, without rename pairing.
/// Why:
/// The landed tree's changes are the paths the landing introduces.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// git diff-tree -r --no-renames --name-only -z from to
/// ```
fn changed_paths(context: &GitContext, cwd: &Path, from: &str, to: &str) -> Result<Vec<String>, GitFailure> {
let request = GitRequest::new(cwd, &["diff-tree", "-r", "--no-renames", "--name-only", "-z", from, to]);
let output: GitOutput = run_git_checked(context, &request)?;
return records(&output.stdout);
}
/// What:
/// Lists paths the landed tree changes relative to what was staged at invocation.
/// Why:
/// When the invocation-time index cannot form a tree, nothing is added by this rule.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// changedSinceCaptured({ gitPath, cwd, capturedIndexPath, landedTreeOid });
/// ```
fn changed_since_captured(
context: &GitContext,
cwd: &Path,
captured: &Path,
landed_tree: &str,
) -> Result<Vec<String>, GitFailure> {
let mut request = GitRequest::new(cwd, &["write-tree"]);
request.index = Some(captured.to_path_buf());
let output: GitOutput = run_git(context, &request)
.map_err(|error| { return GitFailure(format!("git write-tree could not start: {error}")); })?;
if !output.succeeded() {
return Ok(Vec::new());
}
let tree: String = decode(&output.stdout)?.trim().to_string();
return changed_paths(context, cwd, tree.as_str(), landed_tree);
}
/// What:
/// Merges the landed entries of every changed path that nobody restaged since invocation.
/// Why:
/// Other staging stays as it is; only paths still holding their invocation entry take the landed one,
/// and a path the commit deleted is removed from the index.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await runTransactionGit({ indexPath: postIndexPath, args: ['update-index', '-z', '--index-info'], input });
/// ```
fn merge_landed_entries(
context: &GitContext,
cwd: &Path,
artifacts: &Artifacts,
changed: &[String],
) -> Result<(), GitFailure> {
let captured = list_stages(context, cwd, artifacts.captured, changed)?;
let current = list_stages(context, cwd, artifacts.post, changed)?;
let landed = list_stages(context, cwd, artifacts.landed, changed)?;
let untouched: Vec<&String> = changed
.iter()
.filter(|path| {
return captured.get(path.as_str()).map(Vec::as_slice).unwrap_or(&[])
== current.get(path.as_str()).map(Vec::as_slice).unwrap_or(&[]);
})
.collect();
if untouched.is_empty() {
return Ok(());
}
let zero: String = "0".repeat(artifacts.landed_tree.len());
let mut body: String = String::new();
for path in untouched {
match landed.get(path.as_str()) {
Some(entries) => {
for entry in entries {
body.push_str(entry);
body.push('\0');
}
}
None => {
body.push_str(&format!("0 {zero}\t{path}\0"));
}
}
}
let mut request = GitRequest::new(cwd, &["update-index", "-z", "--index-info"]);
request.index = Some(artifacts.post.to_path_buf());
request.input = Some(body.into_bytes());
run_git_checked(context, &request)?;
return Ok(());
}
/// What:
/// The four index files and the landed tree of one computation.
/// Why:
/// The helpers take the same paths together; grouping keeps their signatures short.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// { capturedIndexPath, postIndexPath, landedIndexPath, landedTreeOid }
/// ```
struct Artifacts<'artifacts> {
/// Invocation-time index copy.
captured: &'artifacts Path,
/// Post-index being written.
post: &'artifacts Path,
/// Private index the commit was made from.
landed: &'artifacts Path,
/// Tree the landing installs.
landed_tree: &'artifacts str,
}
/// What:
/// Resets guarded paths to the landed tree while they still hold their invocation entry.
/// Why:
/// A path restaged after invocation keeps the staging the other process made, and the warning names it.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await resetGuardedPaths({ gitPath, cwd, capturedIndexPath, postIndexPath, landedTreeOid, guardedPaths });
/// ```
fn reset_guarded_paths(
context: &GitContext,
cwd: &Path,
artifacts: &Artifacts,
guarded: &[String],
) -> Result<(), GitFailure> {
if guarded.is_empty() {
return Ok(());
}
let captured = list_stages(context, cwd, artifacts.captured, guarded)?;
let current = list_stages(context, cwd, artifacts.post, guarded)?;
let mut untouched: Vec<&String> = Vec::new();
for path in guarded {
let before: &[String] = captured.get(path.as_str()).map(Vec::as_slice).unwrap_or(&[]);
let now: &[String] = current.get(path.as_str()).map(Vec::as_slice).unwrap_or(&[]);
if before == now {
untouched.push(path);
} else {
warn(
TAG,
&format!(
"A commit hook changed {path}, but it was staged again while the commit ran; the committed bytes are in HEAD and your staged entry was kept. Compare them with git diff --cached -- {path}."
),
);
}
}
if untouched.is_empty() {
return Ok(());
}
let mut arguments: Vec<String> = vec!["reset".into(), "--quiet".into(), artifacts.landed_tree.to_string(), "--".into()];
arguments.extend(untouched.iter().map(|path| format!(":(top,literal){path}")));
let mut request = GitRequest::new(cwd, &arguments);
request.index = Some(artifacts.post.to_path_buf());
request.environment = literal_pathspecs();
run_git_checked(context, &request)?;
return Ok(());
}
/// What:
/// Computes the post-index a landing installs.
/// Why:
/// The landing's real index must reflect this commit and every other process's staging at the moment of landing.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await computeLandingPostIndex({ gitPath, cwd, mode, ... });
/// ```
pub fn compute_landing_post_index(
context: &GitContext,
cwd: &Path,
input: &PostIndexInput,
) -> Result<(), GitFailure> {
let artifacts = Artifacts {
captured: input.captured_index,
post: input.post_index,
landed: input.landed_index,
landed_tree: input.landed_tree,
};
if input.mode == TransactionMode::ExplicitPath {
copy(input.pre_landing_index, input.post_index)?;
if !input.committed_paths.is_empty() {
let mut arguments: Vec<String> = vec!["reset".into(), "--quiet".into(), input.landed_tree.to_string(), "--".into()];
arguments.extend(input.committed_paths.iter().map(|path| format!(":(top,literal){path}")));
let mut request = GitRequest::new(cwd, &arguments);
request.index = Some(input.post_index.to_path_buf());
request.environment = literal_pathspecs();
run_git_checked(context, &request)?;
}
let outside: Vec<String> = input
.guarded_paths
.iter()
.filter(|path| { return !input.committed_paths.contains(path); })
.cloned()
.collect();
return reset_guarded_paths(context, cwd, &artifacts, &outside);
}
if input.exact_private_index && files_equal(input.pre_landing_index, input.captured_index)
.map_err(|error| { return GitFailure(error.0); })?
{
copy(input.landed_index, input.post_index)?;
return Ok(());
}
warn(TAG, "real index changed since invocation; merging landed entries per path");
copy(input.pre_landing_index, input.post_index)?;
let mut changed: Vec<String> = changed_paths(context, cwd, input.base_revision, input.landed_tree)?;
for path in changed_since_captured(context, cwd, input.captured_index, input.landed_tree)? {
if !changed.contains(&path) {
changed.push(path);
}
}
return merge_landed_entries(context, cwd, &artifacts, &changed);
}
/// What:
/// Copies one index file with the timestamps Git reads.
/// Why:
/// The post-index keeps the stat data Git uses to skip re-hashing.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await copyIndexFile({ sourcePath, destinationPath });
/// ```
fn copy(source: &Path, destination: &Path) -> Result<(), GitFailure> {
return copy_index_with_timestamps(source, destination)
.map_err(|error| { return GitFailure(format!("copying {} failed: {error}", source.display())); });
}
/// Post-index controls stay out of the production code path.
#[cfg(test)]
#[path = "landing_index_tests.rs"]
mod tests;