anvilsign in

collin/anvil

main / crates / anvil-git / src / edit.rs
1//! Web-driven file edits: write a new commit directly onto a branch of a
2//! bare repository with gix — no working tree, no index.
3//!
4//! The new blob, the rebuilt trees along the file's path, and the commit
5//! object are written to the object database, then the branch ref is
6//! advanced with a compare-and-swap (the transaction insists the tip still
7//! matches what the editor saw — a concurrent push loses nobody's work, the
8//! web edit is simply rejected and re-offered). The commit is a plain child
9//! of the old tip, so clients that pushed earlier can fast-forward pull.
10
11use std::path::Path;
12
13use gix::objs::tree;
14
15/// Why an edit didn't commit. `BranchMoved` and `NoChanges` are normal
16/// outcomes the UI explains; `Other` is a real failure.
17#[derive(Debug)]
18pub enum EditError {
19 /// The branch tip no longer matches what the editor was looking at.
20 BranchMoved {
21 current: String,
22 },
23 /// The new content is identical to what's already committed.
24 NoChanges,
25 Other(String),
26}
27
28impl std::fmt::Display for EditError {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 match self {
31 EditError::BranchMoved { current } => {
32 write!(f, "branch moved (now at {current})")
33 }
34 EditError::NoChanges => write!(f, "no changes"),
35 EditError::Other(m) => write!(f, "{m}"),
36 }
37 }
38}
39
40fn other(e: impl std::fmt::Display) -> EditError {
41 EditError::Other(e.to_string())
42}
43
44/// Replace `file_path`'s content on `branch` with a new commit authored by
45/// `author_name`/`author_email`, expecting the branch tip to be
46/// `expected_tip` (full hex). Returns the new commit id.
47///
48/// Only existing files can be edited (no creation/deletion here); the
49/// entry's mode is preserved, so editing an executable keeps it executable.
50#[allow(clippy::too_many_arguments)]
51pub fn commit_file_change(
52 repo_path: &Path,
53 branch: &str,
54 expected_tip: &str,
55 file_path: &str,
56 content: &[u8],
57 author_name: &str,
58 author_email: &str,
59 message: &str,
60) -> Result<String, EditError> {
61 let repo = gix::open(repo_path).map_err(other)?;
62
63 let tip = crate::browse::resolve_commit(repo_path, &format!("refs/heads/{branch}"))
64 .map_err(|_| EditError::Other(format!("no such branch: {branch}")))?;
65 if tip != expected_tip {
66 return Err(EditError::BranchMoved { current: tip });
67 }
68 let tip_id = gix::ObjectId::from_hex(tip.as_bytes()).map_err(other)?;
69
70 let root_tree = repo
71 .find_object(tip_id)
72 .map_err(other)?
73 .peel_to_commit()
74 .map_err(other)?
75 .tree_id()
76 .map_err(other)?
77 .detach();
78
79 let blob_id = repo.write_blob(content).map_err(other)?.detach();
80 let components: Vec<&str> = file_path.split('/').filter(|c| !c.is_empty()).collect();
81 if components.is_empty() {
82 return Err(EditError::Other("empty path".into()));
83 }
84 let new_root = replace_in_tree(&repo, root_tree, &components, blob_id)?;
85 if new_root == root_tree {
86 return Err(EditError::NoChanges);
87 }
88
89 let author = gix::actor::Signature {
90 name: author_name.into(),
91 email: author_email.into(),
92 time: gix::date::Time::now_local_or_utc(),
93 };
94 let commit = gix::objs::Commit {
95 tree: new_root,
96 parents: [tip_id].into_iter().collect(),
97 author: author.clone(),
98 committer: author,
99 encoding: None,
100 message: message.into(),
101 extra_headers: Vec::new(),
102 };
103 let commit_id = repo.write_object(&commit).map_err(other)?.detach();
104
105 // The compare-and-swap: the update only lands if the tip is still the
106 // one the editor saw. A racing push makes this fail cleanly.
107 use gix::refs::{
108 Target,
109 transaction::{
110 Change,
111 LogChange,
112 PreviousValue,
113 RefEdit,
114 RefLog,
115 },
116 };
117 let name: gix::refs::FullName = format!("refs/heads/{branch}")
118 .try_into()
119 .map_err(|e: gix::validate::reference::name::Error| other(e))?;
120 repo.edit_reference(RefEdit {
121 change: Change::Update {
122 log: LogChange {
123 mode: RefLog::AndReference,
124 force_create_reflog: false,
125 message: "web edit".into(),
126 },
127 expected: PreviousValue::MustExistAndMatch(Target::Object(tip_id)),
128 new: Target::Object(commit_id),
129 },
130 name,
131 deref: false,
132 })
133 .map_err(|e| {
134 // Re-read the tip for a friendlier conflict message; the transaction
135 // error already implies it moved.
136 match crate::browse::resolve_commit(repo_path, &format!("refs/heads/{branch}")) {
137 Ok(current) if current != expected_tip => EditError::BranchMoved { current },
138 _ => other(e),
139 }
140 })?;
141
142 Ok(commit_id.to_string())
143}
144
145/// Rebuild the trees along `components`, swapping the final entry's oid for
146/// `blob_id`. The file must already exist; its mode is preserved.
147fn replace_in_tree(
148 repo: &gix::Repository,
149 tree_id: gix::ObjectId,
150 components: &[&str],
151 blob_id: gix::ObjectId,
152) -> Result<gix::ObjectId, EditError> {
153 let obj = repo.find_object(tree_id).map_err(other)?;
154 let tree_ref =
155 gix::objs::TreeRef::from_bytes(&obj.data, gix::hash::Kind::Sha1).map_err(other)?;
156 let mut tree: gix::objs::Tree = tree_ref.into();
157
158 let (name, rest) = components.split_first().expect("non-empty components");
159 let entry = tree
160 .entries
161 .iter_mut()
162 .find(|e| e.filename == *name)
163 .ok_or_else(|| EditError::Other(format!("no such file in tree: {name}")))?;
164
165 if rest.is_empty() {
166 if !entry.mode.is_blob() {
167 return Err(EditError::Other(format!("{name} is not a file")));
168 }
169 entry.oid = blob_id;
170 } else {
171 if entry.mode != tree::EntryKind::Tree.into() {
172 return Err(EditError::Other(format!("{name} is not a directory")));
173 }
174 entry.oid = replace_in_tree(repo, entry.oid, rest, blob_id)?;
175 }
176
177 Ok(repo.write_object(&tree).map_err(other)?.detach())
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 fn git(dir: &Path, args: &[&str]) {
185 let out = std::process::Command::new("git")
186 .args(args)
187 .current_dir(dir)
188 .env("GIT_AUTHOR_NAME", "t")
189 .env("GIT_AUTHOR_EMAIL", "t@example.com")
190 .env("GIT_COMMITTER_NAME", "t")
191 .env("GIT_COMMITTER_EMAIL", "t@example.com")
192 .output()
193 .expect("run git");
194 assert!(out.status.success(), "git {args:?}: {out:?}");
195 }
196
197 fn fixture(dir: &Path) {
198 git(dir, &["init", "-q", "-b", "main"]);
199 std::fs::create_dir(dir.join("sub")).unwrap();
200 std::fs::write(dir.join("top.txt"), "top\n").unwrap();
201 std::fs::write(dir.join("sub/inner.txt"), "inner\n").unwrap();
202 std::fs::write(dir.join("run.sh"), "#!/bin/sh\n").unwrap();
203 git(dir, &["add", "."]);
204 git(dir, &["update-index", "--chmod=+x", "run.sh"]);
205 git(dir, &["commit", "-qm", "init"]);
206 }
207
208 #[test]
209 fn commits_edits_with_cas_and_preserved_modes() {
210 let tmp = tempfile::tempdir().unwrap();
211 let dir = tmp.path();
212 fixture(dir);
213 let tip = crate::browse::resolve_commit(dir, "main").unwrap();
214
215 // Nested edit advances the branch by exactly one commit.
216 let new = commit_file_change(
217 dir,
218 "main",
219 &tip,
220 "sub/inner.txt",
221 b"changed\n",
222 "alice",
223 "a@anvil",
224 "Update inner",
225 )
226 .unwrap();
227 assert_eq!(crate::browse::resolve_commit(dir, "main").unwrap(), new);
228 let detail = crate::browse::commit_detail(dir, &new).unwrap();
229 assert_eq!(detail.parent.as_deref(), Some(tip.as_str()));
230 assert_eq!(detail.info.author, "alice");
231 assert_eq!(detail.changes.len(), 1, "only the edited file changed");
232 assert_eq!(detail.changes[0].path, "sub/inner.txt");
233 let content = crate::browse::read_blob(dir, "main", "sub/inner.txt")
234 .unwrap()
235 .unwrap();
236 assert_eq!(content, b"changed\n");
237
238 // The stale tip is rejected (CAS), the branch is untouched.
239 let conflict = commit_file_change(
240 dir, "main", &tip, "top.txt", b"x\n", "alice", "a@anvil", "stale",
241 );
242 match conflict {
243 Err(EditError::BranchMoved { current }) => assert_eq!(current, new),
244 other => panic!("expected BranchMoved, got {other:?}"),
245 }
246
247 // Identical content is reported, not committed.
248 let tip2 = crate::browse::resolve_commit(dir, "main").unwrap();
249 assert!(matches!(
250 commit_file_change(
251 dir, "main", &tip2, "top.txt", b"top\n", "alice", "a@anvil", "noop",
252 ),
253 Err(EditError::NoChanges)
254 ));
255
256 // An executable stays executable after an edit (mode preserved):
257 // verify with git itself.
258 let tip3 = crate::browse::resolve_commit(dir, "main").unwrap();
259 commit_file_change(
260 dir,
261 "main",
262 &tip3,
263 "run.sh",
264 b"#!/bin/sh\necho hi\n",
265 "alice",
266 "a@anvil",
267 "edit sh",
268 )
269 .unwrap();
270 let out = std::process::Command::new("git")
271 .args(["ls-tree", "main", "run.sh"])
272 .current_dir(dir)
273 .output()
274 .unwrap();
275 assert!(String::from_utf8_lossy(&out.stdout).starts_with("100755"));
276
277 // Editing a missing file fails cleanly.
278 let tip4 = crate::browse::resolve_commit(dir, "main").unwrap();
279 assert!(matches!(
280 commit_file_change(dir, "main", &tip4, "nope.txt", b"x", "a", "a@a", "m"),
281 Err(EditError::Other(_))
282 ));
283
284 // The repository stays consistent for real git after all of this.
285 let out = std::process::Command::new("git")
286 .args(["fsck", "--strict"])
287 .current_dir(dir)
288 .output()
289 .unwrap();
290 assert!(out.status.success(), "git fsck: {out:?}");
291 }
292}