Jump to…
snowinitial commitqoxwzsukwmkx1mo
1//! Committing a single edited file.
2//!
3//! This is the write path behind in-browser editing. It is deliberately the
4//! narrowest write the store offers: one file, one parent, fast-forward only.
5//!
6//! **Fast-forward only, by compare-and-swap.** The commit is written with the
7//! bookmark's current tip as its sole parent, and the ref update requires the
8//! tip to still be that value. Two people editing the same file at once means
9//! the second one is refused and re-reads, rather than one edit silently
10//! disappearing — the failure mode a naive "read, modify, write ref" has.
11//!
12//! It is also why this path does not need the pre-receive hook: every commit it
13//! makes advances the bookmark by one, so the "cannot be force-updated" rule
14//! for protected bookmarks is satisfied by construction rather than by a check.
15//!
16//! No jj change id is written. A commit made here is a plain-Git commit and the
17//! indexer gives it a synthetic, patch-derived identity like any other (spec
18//! §4) — minting a change id the server invented would be claiming jj
19//! provenance for something jj never touched.
20
21use gix::refs::transaction::{Change, PreviousValue, RefEdit};
22use gix::refs::{FullName, Target};
23
24use crate::path as safe_path;
25use crate::{EditOutcome, Result, RevId, Signature, StoreError};
26
27/// Largest file this path will write.
28///
29/// The editor refuses to open anything bigger, so a body over this is either a
30/// hand-made request or a paste of something that does not belong in an editor.
31const MAX_EDIT_BYTES: usize = 1024 * 1024;
32
33/// Replace `path` with `content` on `bookmark` and commit.
34pub fn commit_file(
35 repo: &gix::Repository,
36 bookmark: &str,
37 expected_tip: &RevId,
38 path: &str,
39 content: &[u8],
40 message: &str,
41 author: &Signature,
42) -> Result<EditOutcome> {
43 if content.len() > MAX_EDIT_BYTES {
44 return Err(StoreError::TooLarge {
45 size: content.len() as u64,
46 limit: MAX_EDIT_BYTES as u64,
47 });
48 }
49
50 // The same normalisation every read goes through: `..`, absolute paths and
51 // control bytes are refused here, not deeper down (spec §9).
52 let rel = safe_path::normalise(path)?;
53 if rel.is_empty() {
54 return Err(StoreError::IsDirectory);
55 }
56
57 let full = format!("refs/heads/{bookmark}");
58 let name: FullName = full
59 .as_str()
60 .try_into()
61 .map_err(|_| StoreError::Path(crate::path::PathError::Control))?;
62
63 let tip = repo
64 .find_reference(&full)
65 .map_err(|_| StoreError::NoSuchRevision)?
66 .try_id()
67 .ok_or_else(|| StoreError::Other(anyhow::anyhow!("{bookmark} is a symbolic reference")))?
68 .detach();
69
70 // The edit was composed against a specific revision. If the bookmark has
71 // moved since, the edit may be based on content that no longer exists.
72 let expected = super::convert::parse_oid(repo, expected_tip)?;
73 if tip != expected {
74 return Ok(EditOutcome::Stale {
75 current: RevId::from_stored(tip.to_string()),
76 });
77 }
78
79 let parent = repo
80 .find_object(tip)
81 .map_err(|_| StoreError::NoSuchRevision)?
82 .try_into_commit()
83 .map_err(|_| StoreError::NoSuchRevision)?;
84 let base_tree = parent
85 .tree()
86 .map_err(|e| StoreError::Other(anyhow::anyhow!("reading tree: {e}")))?;
87
88 // Preserve the existing mode, so editing a script does not silently make it
89 // non-executable. A new file is a plain blob.
90 let existing_mode = base_tree
91 .lookup_entry_by_path(&rel)
92 .map_err(|e| StoreError::Other(anyhow::anyhow!("tree lookup: {e}")))?
93 .map(|e| e.mode());
94
95 let kind = match existing_mode.map(|m| m.kind()) {
96 Some(gix::object::tree::EntryKind::BlobExecutable) => {
97 gix::object::tree::EntryKind::BlobExecutable
98 }
99 // Refusing rather than overwriting: a symlink or a submodule at this
100 // path is not a file the editor was showing, and replacing one with a
101 // blob would be a destructive surprise.
102 Some(gix::object::tree::EntryKind::Link) | Some(gix::object::tree::EntryKind::Commit) => {
103 return Err(StoreError::NoSuchPath)
104 }
105 Some(gix::object::tree::EntryKind::Tree) => return Err(StoreError::IsDirectory),
106 _ => gix::object::tree::EntryKind::Blob,
107 };
108
109 let blob = repo
110 .write_blob(content)
111 .map_err(|e| StoreError::Other(anyhow::anyhow!("writing blob: {e}")))?
112 .detach();
113
114 let mut editor = repo
115 .edit_tree(base_tree.id)
116 .map_err(|e| StoreError::Other(anyhow::anyhow!("opening a tree editor: {e}")))?;
117 editor
118 .upsert(rel.as_str(), kind, blob)
119 .map_err(|e| StoreError::Other(anyhow::anyhow!("updating the tree: {e}")))?;
120 let new_tree = editor
121 .write()
122 .map_err(|e| StoreError::Other(anyhow::anyhow!("writing the tree: {e}")))?
123 .detach();
124
125 // An edit that changes nothing must not create an empty commit — the user
126 // pressed save on an unmodified file, which is not a change.
127 if new_tree
128 == base_tree
129 .id()
130 .detach()
131 {
132 return Ok(EditOutcome::Unchanged);
133 }
134
135 let sig = gix::actor::Signature {
136 name: author.name.clone().into(),
137 email: author.email.clone().into(),
138 time: gix::date::Time::new(author.when.timestamp(), 0),
139 };
140 let sig_ref = sig.to_ref();
141
142 let commit = repo
143 .commit_as(sig_ref, sig_ref, name.as_ref().as_bstr(), message, new_tree, [tip])
144 .map_err(|e| StoreError::Other(anyhow::anyhow!("writing the commit: {e}")))?
145 .detach();
146
147 // `commit_as` moves the reference, but without a constraint. Re-assert the
148 // value we based the commit on: if another writer landed between the read
149 // above and here, this fails and the caller retries rather than clobbering.
150 repo.edit_reference(RefEdit {
151 change: Change::Update {
152 log: gix::refs::transaction::LogChange {
153 mode: gix::refs::transaction::RefLog::AndReference,
154 force_create_reflog: false,
155 message: "edit: commit from the web editor".into(),
156 },
157 expected: PreviousValue::MustExistAndMatch(Target::Object(commit)),
158 new: Target::Object(commit),
159 },
160 name,
161 deref: false,
162 })
163 .map_err(|e| StoreError::Other(anyhow::anyhow!("confirming the bookmark update: {e}")))?;
164
165 Ok(EditOutcome::Committed {
166 rev: RevId::from_stored(commit.to_string()),
167 })
168}

168 lines · Rust