git-ents.gitmain
⌘K
foforge
command.rs259 lines · 8.3 KB · rusthistorycomment on this file
1//! `git ents toolchain`: import a local `bin/` directory as an embedded
2//! toolchain manifest, view its provenance, and show its import history
3//! (`model.toolchain`, `effect.toolchains`).
4//!
5//! Generalized over the same trait-object/generic seam `ents_effect::run`
6//! uses (`&dyn RefStore`/`RefStoreRead`, `impl Find`/`Find + Write`, `&dyn
7//! ents_receive::EventSink`) rather than any concrete composition-root
8//! type, so this crate never depends on a CLI or a specific store
9//! implementation — a composition root wires the concrete types and calls
10//! these functions, never the other way around.
11//!
12//! Only [`super::Recipe::Embedded`] is wired here (`--from` recipes —
13//! `rustup`, `sccache`, `url` — are `pre-redo` extras this phase's spec
14//! does not name; deferred, see this crate's own final report).
15#![expect(
16 clippy::result_large_err,
17 reason = "every function here returns ents_effect::Result — that crate's own Error type, \
18 reused as-is rather than wrapped, since the toolchain domain's errors already live \
19 naturally in its enum; not this crate's to box"
20)]
21
22use std::path::Path;
23
24use ents_effect::{Error, Result};
25use ents_model::namespace;
26use ents_receive::{EventSink, Identity, Mode, Outcome, propose_entity};
27use gix_hash::ObjectId;
28use gix_object::tree::{Entry, EntryKind};
29use gix_object::{CommitRef, Find, Kind, Tree, Write};
30use gix_ref_store::{RefStore, RefStoreRead};
31
32use super::{Recipe, Toolchain, resolve};
33
34/// `git ents toolchain list`: every toolchain name currently defined.
35///
36/// # Errors
37///
38/// Propagates a ref-store read failure.
39///
40/// # Examples
41///
42/// ```
43/// use ents_kiln::toolchain::list;
44/// use ents_testutil::MemRefStore;
45///
46/// let refs = MemRefStore::default();
47/// assert!(list(&refs).expect("reads").is_empty());
48/// ```
49pub fn list(refs: &dyn RefStoreRead) -> Result<Vec<String>> {
50 let mut out = Vec::new();
51 for entry in refs.iter_prefix("refs/meta/toolchains/")? {
52 let (name, _) = entry?;
53 let path = name.as_bstr().to_string();
54 if let Some(rest) = path.strip_prefix("refs/meta/toolchains/") {
55 out.push(rest.to_owned());
56 }
57 }
58 Ok(out)
59}
60
61/// `git ents toolchain import`: embed `bin` whole as toolchain `name`.
62///
63/// Returns the raw [`Outcome`] `receive` reached — callers interpret it
64/// themselves (the CLI's own `outcome_to_result`, for instance), the same
65/// shape `ents_effect::run::run_one` and `ents_forge::comment::add` return
66/// their own raw `Outcome` in.
67///
68/// # Errors
69///
70/// [`Error::Io`] if `bin` cannot be walked; otherwise propagates
71/// serialization or `receive` failures.
72pub fn import(
73 refs: &dyn RefStore,
74 objects: &(impl Find + Write),
75 events: &dyn EventSink,
76 bin: &Path,
77 name: &str,
78 identity: &Identity<'_>,
79 mode: Mode,
80) -> Result<Outcome> {
81 let tree = write_dir_as_tree(bin, objects)?;
82 register(
83 refs,
84 objects,
85 events,
86 name,
87 &Recipe::Embedded { tree },
88 identity,
89 mode,
90 )
91}
92
93/// Record toolchain `name` from an already-parsed [`Recipe`] -- the
94/// counterpart of [`import`] for a recipe that arrives as text
95/// ([`Recipe::parse`], say from a web form) rather than as a local
96/// directory to embed; [`import`] itself delegates here once its
97/// directory walk has produced the embedded tree.
98///
99/// Returns the raw [`Outcome`] `receive` reached, as [`import`] does.
100///
101/// # Errors
102///
103/// [`Error::InvalidToolchainName`] if `name` cannot form a ref;
104/// otherwise propagates serialization or `receive` failures.
105pub fn register(
106 refs: &dyn RefStore,
107 objects: &(impl Find + Write),
108 events: &dyn EventSink,
109 name: &str,
110 recipe: &Recipe,
111 identity: &Identity<'_>,
112 mode: Mode,
113) -> Result<Outcome> {
114 let toolchain = Toolchain {
115 name: name.to_owned(),
116 recipe: recipe.render(),
117 };
118 let ref_name = namespace::toolchain_ref(name)
119 .map_err(|_invalid| Error::InvalidToolchainName(name.to_owned()))?;
120 let outcome = propose_entity(
121 refs,
122 objects,
123 events,
124 ref_name,
125 &toolchain,
126 identity,
127 &format!("Import toolchain {name}"),
128 mode,
129 )?;
130 Ok(outcome)
131}
132
133/// `git ents toolchain view`: the toolchain's recorded recipe.
134///
135/// # Errors
136///
137/// Propagates [`resolve`]'s own errors.
138pub fn view(
139 refs: &dyn RefStoreRead,
140 objects: &impl Find,
141 name: &str,
142) -> Result<(Toolchain, Recipe)> {
143 resolve(refs, objects, name)
144}
145
146/// `git ents toolchain log`: every past import, newest first — the ref's
147/// own commit log (first-parent chain).
148///
149/// # Errors
150///
151/// [`Error::NotFound`] if `name` has no toolchain ref; [`Error::Decode`] if
152/// a commit in the chain cannot be read.
153pub fn log(refs: &dyn RefStoreRead, objects: &impl Find, name: &str) -> Result<Vec<ObjectId>> {
154 let ref_name = namespace::toolchain_ref(name)
155 .map_err(|_invalid| Error::InvalidToolchainName(name.to_owned()))?;
156 let Some(tip) = refs.get(ref_name.as_ref())? else {
157 return Err(Error::NotFound {
158 what: format!("toolchain {name}"),
159 });
160 };
161 let mut out = Vec::new();
162 let mut next = Some(tip);
163 while let Some(oid) = next {
164 out.push(oid);
165 let mut buf = Vec::new();
166 let data = objects
167 .try_find(&oid, &mut buf)
168 .map_err(|source| Error::Decode {
169 oid,
170 detail: source.to_string(),
171 })?
172 .ok_or(Error::Missing { oid })?;
173 if data.kind != Kind::Commit {
174 return Err(Error::Decode {
175 oid,
176 detail: "expected a commit".to_owned(),
177 });
178 }
179 let commit = CommitRef::from_bytes(data.data, oid.kind()).map_err(|e| Error::Decode {
180 oid,
181 detail: e.to_string(),
182 })?;
183 next = commit.parents().next();
184 }
185 Ok(out)
186}
187
188/// Recursively write `dir`'s contents into `objects` as a tree, preserving
189/// the executable bit and recursing into subdirectories — the inverse of
190/// `ents_effect::materialize::checkout`. Symlinks and anything that is not
191/// a plain file or directory are refused (`anchor.retention`-style
192/// defensiveness: a toolchain import should never silently embed something
193/// that cannot round-trip through a tree).
194///
195/// # Errors
196///
197/// [`Error::Io`] on a read failure or an unsupported entry kind.
198fn write_dir_as_tree(dir: &Path, objects: &impl Write) -> Result<ObjectId> {
199 let mut entries = Vec::new();
200 let read = std::fs::read_dir(dir).map_err(|source| Error::Io {
201 path: dir.to_owned(),
202 source,
203 })?;
204 for item in read {
205 let item = item.map_err(|source| Error::Io {
206 path: dir.to_owned(),
207 source,
208 })?;
209 let file_type = item.file_type().map_err(|source| Error::Io {
210 path: item.path(),
211 source,
212 })?;
213 let filename = item.file_name().into_string().map_err(|raw| Error::Io {
214 path: dir.join(raw),
215 source: std::io::Error::other("non-UTF-8 filename cannot round-trip through a tree"),
216 })?;
217 let (mode, oid) = if file_type.is_dir() {
218 (EntryKind::Tree, write_dir_as_tree(&item.path(), objects)?)
219 } else if file_type.is_file() {
220 let bytes = std::fs::read(item.path()).map_err(|source| Error::Io {
221 path: item.path(),
222 source,
223 })?;
224 let executable = is_executable(&item.path());
225 let kind = if executable {
226 EntryKind::BlobExecutable
227 } else {
228 EntryKind::Blob
229 };
230 let oid = objects.write_buf(gix_object::Kind::Blob, &bytes)?;
231 (kind, oid)
232 } else {
233 return Err(Error::Io {
234 path: item.path(),
235 source: std::io::Error::other("unsupported entry (symlink or special file)"),
236 });
237 };
238 entries.push(Entry {
239 mode: mode.into(),
240 filename: filename.into(),
241 oid,
242 });
243 }
244 entries.sort();
245 Ok(objects.write(&Tree { entries })?)
246}
247
248#[cfg(unix)]
249fn is_executable(path: &Path) -> bool {
250 use std::os::unix::fs::PermissionsExt as _;
251 std::fs::metadata(path)
252 .map(|meta| meta.permissions().mode() & 0o111 != 0)
253 .unwrap_or(false)
254}
255
256#[cfg(not(unix))]
257fn is_executable(_path: &Path) -> bool {
258 false
259}