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
//! What:
//! What follows the branch move of a landing and of a restart: the conclusion state and the index update, and the decision
//! whether a batch a killed or failed landing left open may be replayed.
//! Why:
//! A replay runs after the lock the landing held was given up or lost, so another program may have staged into the index since.
//! A replay is made only over the very index file the landing saw under its lock (`service_land_start.rs`), under the lock the
//! restart takes, and the head is checked again under that lock; every other case leaves the index as its writers left it,
//! says so in the service's log, and finishes what does not touch the index. The live landing and the restart share the one
//! routine that finishes a batch, so an alternate index and the conclusion state are settled the same way by both
//! (`doc/planning/cli-git-main-commit-within-native-plus-five.md`, "A replay only over the index the landing saw").
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const done = await finishAfterMove(git, intent, lock, journal, headCheck) // true: every obligation of the batch is settled
//! ```
use super::service_head::still_checked_out;
use super::service_instance::instance_of;
use super::service_journal::{INDEXED_EXTENSION, Indexed, Intent, OPEN_EXTENSION, Recovery, Started, indexed_state, list_intents, mark_done, mark_indexed, read_start, recovery_for, remove_start};
use super::service_land_follow::{IndexPaths, build_index, remove_conclusion_state};
use super::service_land_start::witness_of;
use super::service_index_lock::{IndexLock, remove_stale, try_acquire};
use super::autostash_carry::carry_stages;
use super::service_land::LandContext;
use super::service_protocol::FailureCode;
use super::transaction_git::{GitContext, GitRequest, run_git};
use std::path::Path;
/// The index entries a batch's intent names.
pub(super) fn intent_paths(intent: &Intent) -> IndexPaths<'_> {
return if intent.derived { IndexPaths::Derived { old: intent.old.as_str() } } else { IndexPaths::Listed(intent.paths.as_slice()) };
}
/// Removes the conclusion state a landed batch leaves, when it has one. Whether it is gone.
pub(super) fn cleanup_done(git: &GitContext, repository: &Path, intent: &Intent) -> bool {
return intent.cleanup.as_ref().is_none_or(|cleanup| { return remove_conclusion_state(git, repository, cleanup); });
}
/// Finishes what follows the branch move: the conclusion state is removed, then the index entries are updated. Both are
/// idempotent, and they are recorded apart: the installed index is recorded in the journal while the lock is still held
/// (`<batch>.indexed`), so a restart never replays the index update over what another program staged after the lock was given
/// up, whether or not the conclusion state could be removed. Whether both are done.
pub fn follow_up(git: &GitContext, repository: &Path, intent: &Intent, lock: IndexLock, journal: &Path) -> bool {
let cleaned: bool = cleanup_done(git, repository, intent);
if crate::service_fault::fails("build-index") {
return false;
}
// The new index is built beside the real one and installed by the release of the lock the landing holds.
let Ok(built) = build_index(git, repository, intent.new.as_str(), intent_paths(intent), &lock, intent.seq) else {
return false;
};
// The files a conflicted autostash apply left unmerged are unmerged in the index the landing installs.
if let Some(cleanup) = intent.cleanup.as_ref() {
// Git's `update-index` may write the copy, and does not flush it.
built.set_flushed(false);
if !carry_stages(git, repository, cleanup, built.path()) {
// The private copy goes with `built`, which is dropped here.
return false;
}
}
let recorded = || -> std::io::Result<()> { return mark_indexed(journal, intent.seq, intent.new.as_str()); };
return lock.release_flushed_with_record(built.path(), built.is_flushed(), &recorded).is_ok() && cleaned;
}
/// What:
/// Settles a batch whose branch has moved, while its caller holds the index lock: whether the worktree's head still follows the
/// move decides if the index is updated, and the conclusion state is removed in every case. Whether the batch is done.
/// Why:
/// A worktree that left the batch's branch has a default index that describes another branch, so that index is not updated;
/// an index kept in another file (`GIT_INDEX_FILE`) belongs to no branch and is still finished. A head that cannot be read
/// decides nothing: the lock is given up, the index is not touched, and the batch stays open for a restart to settle.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const done = await finishAfterMove(git, intent, lock, journal, followsMove)
/// ```
pub fn finish_after_move(git: &GitContext, intent: &Intent, lock: IndexLock, journal: &Path, follows_move: Result<bool, FailureCode>) -> bool {
let repository: &Path = intent.repository.as_path();
let own_index: bool = intent.index == intent.git_dir.join("index");
return match follows_move {
Ok(false) if own_index => {
drop(lock);
cleanup_done(git, repository, intent)
}
Ok(_) => follow_up(git, repository, intent, lock, journal),
Err(code) => {
drop(lock);
crate::service_error!("batch {} stays open: the head of the worktree cannot be read ({code:?}), so the index is left as it is", intent.seq);
false
}
};
}
/// What a restart makes of the evidence for replaying an open batch's index update.
#[derive(Debug, Eq, PartialEq)]
enum Gate {
/// The index file is the one the landing saw: nothing was staged since.
Replay,
/// The evidence cannot be read now: the batch stays open.
Wait,
/// The index may hold staging: it is left as it is, for this reason.
Leave(&'static str),
}
/// What:
/// Whether the index update of the open batch may be replayed over the index as it is now, judged under the lock the caller
/// holds.
/// Why:
/// The landing recorded the witness of the index file it saw under its lock. A file that is still that file has been written
/// by nobody since, and a replay over it discards nothing. Any other index, and a batch with no record, may hold what another
/// session staged, so no path of it is reset: whether a path holds the entry the landing started from does not say whether it
/// was staged back to that entry, and a reset also rewrites the entries under and above its path.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const gate = gateOf(journal, intent) // 'replay' | 'wait' | { leave: reason }
/// ```
fn gate_of(journal: &Path, intent: &Intent) -> Gate {
let recorded = match read_start(journal, intent.seq) {
Started::Absent => return Gate::Leave("no record says which index file the landing saw"),
Started::Unreadable => return Gate::Wait,
Started::Recorded(witness) => witness,
};
return match witness_of(intent.index.as_path()) {
Ok(now) if now == recorded => Gate::Replay,
Ok(_) => Gate::Leave("the index file is not the one the landing saw, so another program may have staged into it"),
Err(_) => Gate::Wait,
};
}
/// What a restart made of one open batch.
#[derive(Debug, Eq, PartialEq)]
pub(super) enum Settled {
/// Every obligation of the batch is settled.
Done,
/// Something is left, and the batch stays open.
Open,
}
/// Settles one batch whose branch has moved, found open.
pub(super) fn settle(git: &GitContext, intent: &Intent, journal: &Path) -> Settled {
let repository: &Path = intent.repository.as_path();
let own_index: bool = intent.index == intent.git_dir.join("index");
let head = || { return still_checked_out(git, repository, intent.git_dir.as_path(), intent.common_dir.as_path(), intent.reference.as_str()); };
let done = |finished: bool| -> Settled { return if finished { Settled::Done } else { Settled::Open }; };
// A worktree that has since left the batch's branch has a default index that describes another branch: it is not updated.
// Skipping the index is safe whatever the head does next, so this look needs no lock. A head that cannot be read stops here.
if own_index {
match head() {
Ok(false) => return done(cleanup_done(git, repository, intent)),
Ok(true) => {}
Err(code) => {
crate::service_error!("batch {} stays open: the head of the worktree cannot be read ({code:?}), so the index is left as it is", intent.seq);
return Settled::Open;
}
}
}
match indexed_state(journal, intent.seq, intent.new.as_str()) {
// The installed index is recorded: it is not replayed, and only the conclusion state may be left.
Indexed::Installed => return done(cleanup_done(git, repository, intent)),
// A record that cannot be read may say the index was installed: the batch stays open and the index alone.
Indexed::Unreadable => {
crate::service_error!("batch {} stays open: its completion record cannot be read, so the index is left as it is", intent.seq);
return Settled::Open;
}
Indexed::Absent => {}
}
crate::service_fault::reach("restart-unlocked");
let Some(held) = try_acquire(intent.index.as_path(), intent.seq) else {
return Settled::Open;
};
crate::service_fault::reach("restart-locked");
// Everything the replay depends on is judged again under the lock this restart holds: the head, then the index file.
if own_index {
match head() {
Ok(true) => {}
Ok(false) => {
drop(held);
return done(cleanup_done(git, repository, intent));
}
Err(code) => {
drop(held);
crate::service_error!("batch {} stays open: the head of the worktree cannot be read under the lock ({code:?}), so the index is left as it is", intent.seq);
return Settled::Open;
}
}
}
return match gate_of(journal, intent) {
Gate::Replay => done(follow_up(git, repository, intent, held, journal)),
Gate::Wait => {
drop(held);
crate::service_error!("batch {} stays open: the record of the index file its landing saw cannot be read now", intent.seq);
Settled::Open
}
Gate::Leave(reason) => {
drop(held);
crate::service_error!(
"batch {}: the index entries of its paths were not updated and are left as they are, because {reason}; `git status` shows the commit's paths against the index until they are staged again",
intent.seq
);
done(cleanup_done(git, repository, intent))
}
};
}
/// One thing a restart did, for the service's log.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RecoveryReport {
/// The batch.
pub seq: u64,
/// What was decided.
pub decision: Recovery,
}
/// What:
/// Finishes or drops every batch the journal holds open.
/// Why:
/// Runs before the service accepts a connection, and again before each batch (`service_server.rs`), so an index update
/// that gave up while another program held Git's index lock is finished by the next commit and not only by a restart.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await recover(context)
/// ```
pub fn recover(context: &LandContext) -> Vec<RecoveryReport> {
return recover_selected(context, &|_| { return true; }, true);
}
/// What:
/// Finishes or drops the open batches of the worktree one instance serves, found in a journal that other worktrees' batches
/// may share.
/// Why:
/// The single service of the releases before one service per worktree kept one journal for every worktree, in the directory
/// above each instance's own, and an instance takes over the batches of its worktree there (found by the Git directory the
/// intent records, which an index outside it, as `GIT_INDEX_FILE` names one, cannot change; an intent that records none is
/// resolved from its repository). A development run may also give several instances one journal. Whatever belongs to
/// another worktree is left to its instance, and a record that cannot be read is not any instance's to remove.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await recoverInstance(context, instance)
/// ```
pub fn recover_instance(context: &LandContext, instance: &str) -> Vec<RecoveryReport> {
let ours = |intent: &Intent| -> bool { return instance_of(intent.git_dir.as_path()) == instance; };
return recover_selected(context, &ours, false);
}
/// The value of `reference` in the repository, empty when it cannot be read.
fn current_value(git: &GitContext, intent: &Intent) -> String {
let request: GitRequest = GitRequest::new(intent.repository.as_path(), &["rev-parse", "--verify", intent.reference.as_str()]);
return match run_git(git, &request) {
Ok(output) if output.succeeded() => String::from_utf8_lossy(output.stdout.as_slice()).trim().to_string(),
_ => String::new(),
};
}
/// Whether the commit the batch landed is `current` or below it.
fn landed_below(git: &GitContext, intent: &Intent, current: &str) -> bool {
let request: GitRequest = GitRequest::new(intent.repository.as_path(), &["merge-base", "--is-ancestor", intent.new.as_str(), current]);
return run_git(git, &request).is_ok_and(|output| { return output.succeeded(); });
}
fn recover_selected(context: &LandContext, selected: &dyn Fn(&Intent) -> bool, remove_unreadable: bool) -> Vec<RecoveryReport> {
let mut reports: Vec<RecoveryReport> = Vec::new();
for (path, parsed) in list_intents(context.journal.as_path(), OPEN_EXTENSION) {
let Some(intent) = parsed else {
if remove_unreadable {
let _removed = std::fs::remove_file(&path);
}
continue;
};
if !selected(&intent) {
continue;
}
let git: GitContext = GitContext { common_dir: Some(intent.common_dir.clone()), ..context.git.clone() };
let current: String = current_value(&git, &intent);
let ancestor: bool = landed_below(&git, &intent, current.as_str());
let decision: Recovery = recovery_for(&intent, current.as_str(), ancestor);
match decision {
Recovery::Drop => {
remove_stale(intent.index.as_path(), intent.seq);
let _removed = std::fs::remove_file(&path);
let _record_removed = std::fs::remove_file(super::service_journal::batch_path(context.journal.as_path(), intent.seq, INDEXED_EXTENSION));
remove_start(context.journal.as_path(), intent.seq);
}
Recovery::Finish => {
remove_stale(intent.index.as_path(), intent.seq);
if matches!(settle(&git, &intent, context.journal.as_path()), Settled::Done) {
let _marked = mark_done(context.journal.as_path(), intent.seq);
}
}
}
reports.push(RecoveryReport { seq: intent.seq, decision });
}
return reports;
}