| 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 | |
| 21 | use gix::refs::transaction::{Change, PreviousValue, RefEdit}; |
| 22 | use gix::refs::{FullName, Target}; |
| 23 | |
| 24 | use crate::path as safe_path; |
| 25 | use 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. |
| 31 | const MAX_EDIT_BYTES: usize = 1024 * 1024; |
| 32 | |
| 33 | /// Replace `path` with `content` on `bookmark` and commit. |
| 34 | pub 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