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
use crate::Repository;
use crate::index::{IndexEdit, Limits, StorageError};
use crate::refs::{
Expected, RefEdit, RefEditOutcome, RefName, ReferenceError, Reflog, Target, TransactionError,
};
/// A locked worktree index draft that can be published before a conditional stored HEAD edit.
///
/// Caller policy chooses entries, stat reuse, HEAD target and history. No working files change.
/// Index and HEAD publication cannot be atomic together; readers can observe the new index with
/// old HEAD. A HEAD failure retains the new index and reports its reference/reflog effects.
#[derive(Debug)]
pub struct ColocationEdit<'a> {
repository: &'a Repository,
index: IndexEdit,
}
/// Publication failure identifying whether the index has already changed.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ColocationError {
/// Index publication failed; HEAD was not attempted. The storage error retains cleanup causes.
#[error("colocation index publication failed")]
Index(#[source] StorageError),
/// HEAD validation or locking failed before publication. Neither index nor HEAD changed.
#[error("colocation HEAD preparation failed")]
Prepare {
/// Conditional reference preparation failure.
#[source]
source: TransactionError,
/// Index lock cleanup failure, if any; recover this lock explicitly.
cleanup: Option<Box<StorageError>>,
},
/// The index is published; HEAD publication failed. Do not blindly retry.
#[error("index published but HEAD transaction failed")]
Head(#[source] TransactionError),
/// The reference store could not be opened; index publication was not attempted.
#[error("cannot open colocation references")]
References {
/// Reference backend failure.
#[source]
source: ReferenceError,
/// Index lock cleanup failure, if any.
cleanup: Option<Box<StorageError>>,
},
}
impl Repository {
/// Locks and reads the index for a caller-defined colocation update.
///
/// Use [`ColocationEdit::index_mut`] to derive changes from the locked current index.
/// Keeping this guard alive excludes cooperating index writers. See [`Self::edit_index`]
/// for resource, filesystem and cleanup contracts. Dropping abandons the index draft.
///
/// # Errors
///
/// Returns the same lock, format and read failures as [`Self::edit_index`].
pub fn edit_colocation(&self, limits: Limits) -> Result<ColocationEdit<'_>, StorageError> {
Ok(ColocationEdit {
repository: self,
index: self.edit_index(limits)?,
})
}
}
impl ColocationEdit<'_> {
/// Borrows the held index guard for caller-defined entry/flag/stat edits.
pub fn index_mut(&mut self) -> &mut IndexEdit {
&mut self.index
}
/// Releases the index lock without publishing either index or HEAD.
///
/// # Errors
///
/// Reports lock cleanup failure; original index and HEAD contents remain unchanged.
pub fn abort(self) -> Result<(), StorageError> {
self.index.abort()
}
/// Prepares a conditional HEAD edit, publishes the index, then publishes stored HEAD.
///
/// Direct targets detach HEAD without changing the former branch. Symbolic targets can name
/// an unborn branch; this method never deletes that branch to make it unborn. Caller-supplied
/// preconditions compare stored HEAD under reference locks held through both publications.
/// Reflog policy is explicit and inherits [`crate::refs::References::transaction`]. Object
/// existence/type is not checked. Operation-state cleanup remains a separate caller
/// decision through [`super::OperationState`].
///
/// # Errors
///
/// [`ColocationError::Prepare`] leaves index and HEAD unchanged and releases owned locks.
/// [`ColocationError::Index`] preserves old index bytes and leaves HEAD untouched. A
/// [`ColocationError::Head`] means the index is already published; inspect the nested
/// transaction's per-reference and per-log effects before recovery. No rollback or atomic
/// multi-file visibility is promised. The caller must exclude working-file writers if its
/// chosen draft relies on working-file observations.
pub fn commit(
self,
target: Target,
expected: Expected,
reflog: Reflog,
) -> Result<Vec<RefEditOutcome>, ColocationError> {
#[cfg(feature = "tracing")]
let span = tracing::debug_span!(target: "girt", "colocation.commit", outcome = "incomplete", failure_class = tracing::field::Empty, effects = tracing::field::Empty);
let operation = || self.commit_with(target, expected, reflog, || {});
#[cfg(feature = "tracing")]
let result = span.in_scope(operation);
#[cfg(not(feature = "tracing"))]
let result = operation();
#[cfg(feature = "tracing")]
crate::trace::finish(&span, &result, |error| match error {
ColocationError::Index(error) => crate::trace::index(error, &span),
ColocationError::Prepare { source, .. } => crate::trace::transaction(source, &span),
ColocationError::Head(source) => {
let class = crate::trace::transaction(source, &span);
span.record("effects", "index_published_head_may_have_effects");
class
}
ColocationError::References { .. } => "unsupported",
});
result
}
fn commit_with(
self,
target: Target,
expected: Expected,
reflog: Reflog,
after_index: impl FnOnce(),
) -> Result<Vec<RefEditOutcome>, ColocationError> {
let _lock_operation = crate::locks::operation();
let refs = match self.repository.references() {
Ok(refs) => refs,
Err(source) => {
return Err(ColocationError::References {
source,
cleanup: self.index.abort().err().map(Box::new),
});
}
};
let edit = RefEdit {
name: RefName::new(b"HEAD").expect("HEAD is valid"),
dereference: crate::refs::Dereference::Stored,
target: Some(target),
expected,
reflog,
};
let prepared = match refs.prepare_transaction(&[edit]) {
Ok(prepared) => prepared,
Err(source) => {
return Err(ColocationError::Prepare {
source,
cleanup: self.index.abort().err().map(Box::new),
});
}
};
self.index.commit().map_err(ColocationError::Index)?;
after_index();
prepared.publish().map_err(ColocationError::Head)
}
}
#[cfg(test)]
mod tests {
use std::fs;
use rstest::rstest;
use super::*;
use crate::refs::RefOutcome;
use crate::{InitKind, ObjectFormat, ObjectId};
#[rstest]
#[case::sha1(ObjectFormat::Sha1)]
#[case::sha256(ObjectFormat::Sha256)]
fn head_publication_failure_reports_published_index(#[case] format: ObjectFormat) {
let root = tempfile::tempdir().unwrap();
let repo = Repository::init(format, root.path().join("repo"), InitKind::Worktree).unwrap();
let edit = repo.edit_colocation(Limits::default()).unwrap();
let error = edit
.commit_with(
Target::Direct(ObjectId::for_blob(format, b"id")),
Expected::Exists,
Reflog::Preserve,
|| {
// Force rename failure after both preparations and successful index
// publication.
fs::remove_file(repo.git_dir().join("HEAD")).unwrap();
fs::create_dir(repo.git_dir().join("HEAD")).unwrap();
},
)
.unwrap_err();
let ColocationError::Head(TransactionError::Publish { outcomes, .. }) = error else {
panic!("expected partial publication")
};
assert_eq!(outcomes[0].reference, RefOutcome::Unchanged);
assert!(repo.read_index(Limits::default()).unwrap().is_some());
assert!(!repo.git_dir().join("index.lock").exists());
assert!(!repo.git_dir().join("HEAD.lock").exists());
}
#[rstest]
#[case::sha1(ObjectFormat::Sha1)]
#[case::sha256(ObjectFormat::Sha256)]
fn log_failure_retains_published_head_and_index(#[case] format: ObjectFormat) {
let root = tempfile::tempdir().unwrap();
let repo = Repository::init(format, root.path().join("repo"), InitKind::Worktree).unwrap();
let target = ObjectId::for_blob(format, b"id");
let reflog = Reflog::Append {
committer: crate::Signature {
name: b"Fixture".to_vec(),
email: b"fixture@example.invalid".to_vec(),
seconds: 1,
offset_minutes: 0,
},
message: b"colocation".to_vec(),
};
let edit = repo.edit_colocation(Limits::default()).unwrap();
let error = edit
.commit_with(Target::Direct(target), Expected::Exists, reflog, || {
fs::create_dir(repo.git_dir().join("logs/HEAD")).unwrap();
})
.unwrap_err();
let ColocationError::Head(TransactionError::Publish { outcomes, .. }) = error else {
panic!("expected partial publication")
};
assert_eq!(outcomes[0].reference, RefOutcome::Published);
assert!(matches!(
outcomes[0].logs[0].1,
crate::refs::LogOutcome::Failed { bytes_written: 0 }
));
assert_eq!(
repo.references()
.unwrap()
.read(&RefName::new(b"HEAD").unwrap())
.unwrap(),
Some(Target::Direct(target))
);
assert!(repo.read_index(Limits::default()).unwrap().is_some());
}
}