majordomus_cli/pack/mod.rs
1//! The source pack: the tracked tree as a few text files a language model's file search can
2//! index, with nothing in it that is not source.
3//!
4//! `majordomus archive` (the shell tool) hands a reader a zip. A chat model's project files
5//! are indexed one text file at a time and a zip is not text, so a model that is given the
6//! archive can unpack it in a sandbox but cannot search it. The pack is the same selection,
7//! written as Markdown shards that each stay under a token budget, with an index that
8//! orients the reader and a manifest that lets [`verify`] prove, afterwards, that what
9//! leaves the machine is what was selected and nothing else.
10//!
11//! # What is selected
12//!
13//! The profiles are `share/archive.yaml`'s: one declaration of what a snapshot is for, read
14//! by the shell's archive and by this module alike. The file set is the git index and the
15//! content is the index's blobs, never the working tree, so an ignored file, a build output,
16//! a cache and the local half of the AI layer cannot travel by construction. On top of that
17//! a profile that sets `artifacts: drop` refuses, whatever the index holds:
18//!
19//! - a gitlink (mode `160000`): a nested repository or worktree whose content is not here;
20//! - a symbolic link (mode `120000`): its content is a path on the machine that wrote it;
21//! - a path matching `artifact_paths` (`target/`, `node_modules/`, a `-wt/` worktree
22//! container, minified bundles, ...): build output that was committed by accident;
23//! - content that is not UTF-8 text or holds a NUL byte: a binary whatever its extension.
24//!
25//! An `include` re-admits what `exclude` or `derived: drop` left out, never one of these:
26//! a list that could re-admit a binary is a list that eventually will.
27//!
28//! # What is refused
29//!
30//! The plan is a verdict, not only a selection. A single file over the shard budget, more
31//! shards than the profile allows, a selected file that names this machine (the checkout's
32//! own absolute path or the home directory of the account packing it), and an empty
33//! selection are findings, and a pack with a finding is not built.
34//!
35//! The content is carried exactly as committed and never rewritten. A pack holds nothing
36//! the tracked tree does not, and what the tree may hold is decided where it is committed
37//! (`scripts/ci/no-machine-paths`, the publication checks). Credential shapes are therefore
38//! not redacted here: measured on this repository, the published-text table matches `sk-`
39//! inside ordinary words (`task-in-progress-…`), so redacting would hand the reader prose
40//! that was never written, and a digest of content that was never committed. The one leak
41//! that can arise at packing time and not at commit time is the machine doing the packing,
42//! which is what is refused, with no false positive by construction.
43//!
44//! ```
45//! use majordomus_cli::pack::{DropReason, fence_for};
46//! assert_eq!(DropReason::Worktree.as_str(), "worktree");
47//! // a fence is always longer than the longest run of backticks in what it holds
48//! assert_eq!(fence_for("no ticks"), "```");
49//! assert_eq!(fence_for("a ```` b"), "`````");
50//! ```
51
52mod shard;
53
54use std::collections::{BTreeMap, BTreeSet};
55use std::io::Write;
56use std::path::{Path, PathBuf};
57use std::process::Stdio;
58
59use schemars::JsonSchema;
60use serde::{Deserialize, Serialize};
61
62use crate::discovery::glob::Glob;
63use crate::order::{canonical, OrderKey, Ordered};
64
65pub use shard::{fence_for, verify, PackVerdict};
66
67/// The profiles file of the tool distribution, relative to the share directory.
68pub const PROFILES: &str = "archive.yaml";
69/// A repository's own profiles, relative to its root. A profile declared there under an id
70/// the distribution ships replaces the shipped one whole, as the shell's archive reads it;
71/// the default and the two lists are the distribution's.
72pub const OVERLAY: &str = ".ai/repo/archive.yaml";
73/// The schema word of a pack's manifest.
74pub const MANIFEST_SCHEMA: &str = "majordomus.pack/v1";
75/// The manifest's file name inside a pack.
76pub const MANIFEST: &str = "pack.json";
77/// The orientation document's file name inside a pack; it is shard zero.
78pub const INDEX: &str = "00-INDEX.md";
79/// The share of the token budget a shard is filled to, in percent. The rest absorbs the
80/// difference between counting each file's block and counting the shard it lands in.
81const FILL_PERCENT: u64 = 95;
82/// The paths that name the machine a pack is built on: the checkout itself and the home
83/// directory of the account, each with a trailing separator so that a sibling with the
84/// same prefix is not one.
85///
86/// A path has more than one spelling — the one a shell's `$PWD` prints and the one the
87/// file system resolves it to, and on macOS `/var` and `/tmp` are `/private/var` and
88/// `/private/tmp` — so each is listed in every spelling it has.
89pub(crate) fn machine_paths(root: &Path) -> Vec<String> {
90 machine_paths_for(root, std::env::var_os("HOME").map(PathBuf::from))
91}
92
93/// [`machine_paths`] for the home directory `home`: none, `/`, and a hosted runner's home
94/// name no person's machine and add nothing to the checkout.
95fn machine_paths_for(root: &Path, home: Option<PathBuf>) -> Vec<String> {
96 let mut bases = vec![root.to_path_buf()];
97 bases.extend(home.filter(|h| h.as_os_str().len() > 1 && !is_runner_home(h)));
98 let mut out = BTreeSet::new();
99 for base in bases {
100 let resolved = std::fs::canonicalize(&base).unwrap_or_else(|_| base.clone());
101 for p in [base, resolved] {
102 let s = p.display().to_string();
103 if let Some(short) = s.strip_prefix("/private") {
104 if short.starts_with('/') {
105 out.insert(format!("{short}/"));
106 }
107 }
108 out.insert(format!("{s}/"));
109 }
110 }
111 out.into_iter().collect()
112}
113
114/// Why a tracked file is not in the pack. Every reason but `derived` and `excluded` is
115/// final: no `include` of a profile re-admits a link, a gitlink, an artifact or a binary.
116///
117/// ```
118/// use majordomus_cli::pack::DropReason;
119/// assert_eq!(DropReason::Binary.as_str(), "binary");
120/// let json = serde_json::to_string(&DropReason::Artifact).unwrap();
121/// assert_eq!(json, "\"artifact\"");
122/// ```
123#[derive(
124 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
125)]
126#[serde(rename_all = "snake_case")]
127pub enum DropReason {
128 /// A gitlink: a nested repository or worktree whose content is not in this index.
129 Worktree,
130 /// A symbolic link, whose content is a path on the machine that committed it.
131 Link,
132 /// A path the distribution names as build output, a cache or a worktree container.
133 Artifact,
134 /// Not text: a NUL byte, content that is not UTF-8, or an extension on the binary list.
135 Binary,
136 /// Marked `merge=derived` in `.gitattributes`: a projection of a file that is here.
137 Derived,
138 /// Matched by the profile's `exclude`.
139 Excluded,
140}
141
142impl DropReason {
143 /// The word a report and the manifest use, the same as its serialised form.
144 ///
145 /// ```
146 /// use majordomus_cli::pack::DropReason;
147 /// assert_eq!(DropReason::Link.as_str(), "link");
148 /// assert_eq!(DropReason::Excluded.as_str(), "excluded");
149 /// ```
150 pub fn as_str(self) -> &'static str {
151 match self {
152 DropReason::Worktree => "worktree",
153 DropReason::Link => "link",
154 DropReason::Artifact => "artifact",
155 DropReason::Binary => "binary",
156 DropReason::Derived => "derived",
157 DropReason::Excluded => "excluded",
158 }
159 }
160
161 /// Every reason, in the order of their words, which is the order a report lists them in.
162 pub(crate) const ALL: [DropReason; 6] = [
163 DropReason::Artifact,
164 DropReason::Binary,
165 DropReason::Derived,
166 DropReason::Excluded,
167 DropReason::Link,
168 DropReason::Worktree,
169 ];
170
171 /// What the reason means to a reader of the pack's index.
172 pub(crate) fn meaning(self) -> &'static str {
173 match self {
174 DropReason::Derived => {
175 "generated projections of files that are here (`merge=derived` in `.gitattributes`)"
176 }
177 DropReason::Binary => {
178 "not text: a binary extension, a NUL byte or content that is not UTF-8"
179 }
180 DropReason::Artifact => {
181 "build output, caches and worktree containers committed by accident"
182 }
183 DropReason::Worktree => {
184 "gitlinks: nested repositories or worktrees whose content is not in this index"
185 }
186 DropReason::Link => "symbolic links, whose content is a path on the committing machine",
187 DropReason::Excluded => "matched by the profile's exclude list",
188 }
189 }
190
191 /// Whether an `include` may re-admit a file dropped for this reason.
192 fn reincludable(self) -> bool {
193 matches!(self, DropReason::Derived | DropReason::Excluded)
194 }
195}
196
197/// The shard limits of a profile: the reader's own limits on what it can index, the file
198/// count of a project and the tokens one file of it may hold.
199///
200/// ```
201/// use majordomus_cli::pack::{Profiles, ShardLimits};
202/// let p = Profiles::parse(
203/// "default: a\nprofiles:\n - id: a\n shards:\n max_count: 20\n max_tokens: 900\n",
204/// )
205/// .unwrap();
206/// let want = ShardLimits { max_count: 20, max_tokens: 900 };
207/// assert_eq!(p.profile(None).unwrap().shards, Some(want));
208/// ```
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
210pub struct ShardLimits {
211 /// The most files the pack may hold, the index included.
212 pub max_count: usize,
213 /// The most tokens one file of the pack may hold, counted with `o200k_base`.
214 pub max_tokens: u64,
215}
216
217/// One profile of `share/archive.yaml`, as far as the pack reads it: what it drops, what it
218/// re-admits, what the index says first and the limits it is cut into.
219///
220/// ```
221/// use majordomus_cli::pack::{PackProfile, Profiles};
222/// let p = Profiles::parse("default: a\nprofiles:\n - id: a\n derived: drop\n").unwrap();
223/// let a: &PackProfile = p.profile(Some("a")).unwrap();
224/// assert_eq!(a.derived, "drop");
225/// assert!(a.shards.is_none(), "a profile without limits cannot be packed");
226/// ```
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
228pub struct PackProfile {
229 /// The profile's id.
230 pub id: String,
231 /// One line: what the snapshot is for.
232 pub title: String,
233 /// `drop` leaves out what `.gitattributes` marks `merge=derived`.
234 pub derived: String,
235 /// `drop` leaves out what is not text.
236 pub binary: String,
237 /// `drop` leaves out gitlinks, links and the distribution's artifact paths.
238 pub artifacts: String,
239 /// Globs left out.
240 pub exclude: Vec<String>,
241 /// Globs re-admitted after `exclude` and `derived`.
242 pub include: Vec<String>,
243 /// The paragraphs the index opens with.
244 pub orientation: Vec<String>,
245 /// The shard limits; a profile without them cannot be packed.
246 pub shards: Option<ShardLimits>,
247}
248
249/// What the distribution declares once for every profile: the default, the binary
250/// extensions, the artifact paths, and the profiles themselves.
251///
252/// ```
253/// use majordomus_cli::pack::Profiles;
254/// let p = Profiles::parse(
255/// "default: a\nbinary_extensions: [\"PNG\"]\nprofiles:\n - id: a\n - id: b\n",
256/// )
257/// .unwrap();
258/// assert_eq!(p.default, "a");
259/// assert!(p.binary_extensions.contains("png"), "extensions are compared in lower case");
260/// assert_eq!(p.profiles.len(), 2);
261/// ```
262#[derive(Debug, Clone, Default, PartialEq, Eq)]
263pub struct Profiles {
264 /// The profile used when none is named.
265 pub default: String,
266 /// Extensions, without the dot, that are binary whatever their content.
267 pub binary_extensions: BTreeSet<String>,
268 /// Globs of build output, caches and worktree containers.
269 pub artifact_paths: Vec<String>,
270 /// Every profile, in file order.
271 pub profiles: Vec<PackProfile>,
272}
273
274#[derive(Deserialize)]
275struct RawLimits {
276 max_count: usize,
277 max_tokens: u64,
278}
279
280#[derive(Deserialize)]
281struct RawProfile {
282 id: String,
283 #[serde(default)]
284 title: String,
285 #[serde(default)]
286 derived: String,
287 #[serde(default)]
288 binary: String,
289 #[serde(default)]
290 artifacts: String,
291 #[serde(default)]
292 exclude: Vec<String>,
293 #[serde(default)]
294 include: Vec<String>,
295 #[serde(default)]
296 orientation: Vec<String>,
297 #[serde(default)]
298 shards: Option<RawLimits>,
299}
300
301#[derive(Deserialize)]
302struct RawOverlay {
303 #[serde(default)]
304 profiles: Vec<RawProfile>,
305}
306
307#[derive(Deserialize)]
308struct RawProfiles {
309 default: String,
310 #[serde(default)]
311 binary_extensions: Vec<String>,
312 #[serde(default)]
313 artifact_paths: Vec<String>,
314 profiles: Vec<RawProfile>,
315}
316
317impl From<RawProfile> for PackProfile {
318 fn from(p: RawProfile) -> Self {
319 PackProfile {
320 id: p.id,
321 title: p.title,
322 derived: p.derived,
323 binary: p.binary,
324 artifacts: p.artifacts,
325 exclude: p.exclude,
326 include: p.include,
327 orientation: p.orientation,
328 shards: p.shards.map(|s| ShardLimits {
329 max_count: s.max_count,
330 max_tokens: s.max_tokens,
331 }),
332 }
333 }
334}
335
336impl Profiles {
337 /// Read the profiles from the share directory, then the repository's overlay
338 /// (`.ai/repo/archive.yaml`), whose profiles replace the shipped ones of the same id.
339 ///
340 /// ```
341 /// use majordomus_cli::pack::Profiles;
342 /// let dir = tempfile::tempdir().unwrap();
343 /// let (share, root) = (dir.path().join("share"), dir.path().join("repo"));
344 /// std::fs::create_dir_all(&share).unwrap();
345 /// std::fs::create_dir_all(root.join(".ai/repo")).unwrap();
346 /// std::fs::write(share.join("archive.yaml"), "default: a\nprofiles:\n - id: a\n").unwrap();
347 /// std::fs::write(
348 /// root.join(".ai/repo/archive.yaml"),
349 /// "profiles:\n - id: a\n title: ours\n",
350 /// )
351 /// .unwrap();
352 /// let p = Profiles::load(&share, &root).unwrap();
353 /// assert_eq!(p.profile(None).unwrap().title, "ours");
354 /// assert!(Profiles::load(&root, &root).is_err(), "no archive.yaml there");
355 /// ```
356 pub fn load(share: &Path, root: &Path) -> Result<Profiles, String> {
357 let path = share.join(PROFILES);
358 let text = std::fs::read_to_string(&path)
359 .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
360 let mut profiles =
361 Profiles::parse(&text).map_err(|e| format!("{}: {e}", path.display()))?;
362 let overlay = root.join(OVERLAY);
363 if let Ok(text) = std::fs::read_to_string(&overlay) {
364 let raw: RawOverlay =
365 crate::metadata::yaml::parse_into(&text).map_err(|e| format!("{OVERLAY}: {e}"))?;
366 profiles.overlay(raw.profiles.into_iter().map(PackProfile::from).collect());
367 }
368 Ok(profiles)
369 }
370
371 /// Put a repository's profiles over the shipped ones: the same id replaces, whole.
372 pub(crate) fn overlay(&mut self, local: Vec<PackProfile>) {
373 for p in local.into_iter().rev() {
374 self.profiles.retain(|q| q.id != p.id);
375 self.profiles.insert(0, p);
376 }
377 }
378
379 /// Parse the text of a profiles file, without the overlay: the `default`, the two lists
380 /// and the profiles, with extensions lowered so that `PNG` and `png` are one.
381 ///
382 /// ```
383 /// use majordomus_cli::pack::Profiles;
384 /// let p = Profiles::parse("default: a\nprofiles:\n - id: a\n exclude: [\"x/**\"]\n").unwrap();
385 /// assert_eq!(p.profile(None).unwrap().id, "a");
386 /// assert!(p.profile(Some("b")).is_err());
387 /// ```
388 pub fn parse(text: &str) -> Result<Profiles, String> {
389 let raw: RawProfiles = crate::metadata::yaml::parse_into(text)?;
390 let profiles = raw.profiles.into_iter().map(PackProfile::from).collect();
391 Ok(Profiles {
392 default: raw.default,
393 binary_extensions: raw
394 .binary_extensions
395 .into_iter()
396 .map(|e| e.to_ascii_lowercase())
397 .collect(),
398 artifact_paths: raw.artifact_paths,
399 profiles,
400 })
401 }
402
403 /// The named profile, or the default one when no name is given; an unknown name is
404 /// an error that lists the profiles there are.
405 ///
406 /// ```
407 /// use majordomus_cli::pack::Profiles;
408 /// let p = Profiles::parse("default: a\nprofiles:\n - id: a\n - id: b\n").unwrap();
409 /// assert_eq!(p.profile(Some("b")).unwrap().id, "b");
410 /// let e = p.profile(Some("c")).unwrap_err();
411 /// assert!(e.contains("a, b"), "{e}");
412 /// ```
413 pub fn profile(&self, id: Option<&str>) -> Result<&PackProfile, String> {
414 let id = id.unwrap_or(&self.default);
415 self.profiles.iter().find(|p| p.id == id).ok_or_else(|| {
416 let known: Vec<&str> = self.profiles.iter().map(|p| p.id.as_str()).collect();
417 format!("no profile '{id}'; the profiles are {}", known.join(", "))
418 })
419 }
420
421 /// Is `path` one of the distribution's artifact paths: build output, a cache or a
422 /// worktree container that was committed by accident?
423 ///
424 /// ```
425 /// use majordomus_cli::pack::Profiles;
426 /// let p = Profiles::parse(
427 /// "default: a\nartifact_paths: [\"**/target/**\"]\nprofiles:\n - id: a\n",
428 /// )
429 /// .unwrap();
430 /// assert!(p.is_artifact("apps/x/target/debug/y.rs"));
431 /// assert!(!p.is_artifact("apps/x/src/target.rs"));
432 /// ```
433 pub fn is_artifact(&self, path: &str) -> bool {
434 self.artifact_paths
435 .iter()
436 .any(|g| Glob::new(g).matches(path))
437 }
438
439 /// Does `path` carry an extension on the binary list? The extension is the file name's
440 /// last, compared in lower case; a dot file such as `.png` has none.
441 ///
442 /// ```
443 /// use majordomus_cli::pack::Profiles;
444 /// let p = Profiles::parse("default: a\nbinary_extensions: [png]\nprofiles:\n - id: a\n")
445 /// .unwrap();
446 /// assert!(p.has_binary_extension("site/logo.PNG"));
447 /// assert!(!p.has_binary_extension("dir.png/readme.md"));
448 /// assert!(!p.has_binary_extension(".png"));
449 /// ```
450 pub fn has_binary_extension(&self, path: &str) -> bool {
451 let name = path.rsplit('/').next().unwrap_or(path);
452 match name.rsplit_once('.') {
453 Some((stem, ext)) if !stem.is_empty() => {
454 self.binary_extensions.contains(&ext.to_ascii_lowercase())
455 }
456 _ => false,
457 }
458 }
459}
460
461/// Is this content text the pack can carry? UTF-8 and free of NUL bytes.
462///
463/// ```
464/// use majordomus_cli::pack::is_text;
465/// assert!(is_text(b"fn main() {}\n"));
466/// assert!(!is_text(b"PNG\0\x01"));
467/// assert!(!is_text(&[0xff, 0xfe, 0x41]));
468/// ```
469pub fn is_text(content: &[u8]) -> bool {
470 !content.contains(&0) && std::str::from_utf8(content).is_ok()
471}
472
473/// One entry of the git index.
474#[derive(Debug, Clone, PartialEq, Eq)]
475pub(crate) struct IndexEntry {
476 /// The mode, as git prints it (`100644`, `100755`, `120000`, `160000`).
477 pub mode: String,
478 /// The blob or commit id.
479 pub oid: String,
480 /// The path, relative to the repository root.
481 pub path: String,
482}
483
484/// A file the pack carries, with its content.
485#[derive(Debug, Clone)]
486pub(crate) struct Selected {
487 /// The index entry.
488 pub entry: IndexEntry,
489 /// The content of the index's blob.
490 pub text: String,
491 /// The tokens of the block that carries it in a shard.
492 pub tokens: u64,
493}
494
495/// One planned shard: a Markdown file of the pack, the paths it carries from first to last,
496/// and what it weighs in bytes and in tokens.
497///
498/// The examples below plan a repository holding `main.rs` and a `logo.png` whose content is
499/// binary, with a distribution whose `chat` profile has limits and whose `bare` one has none.
500///
501/// ```
502/// use majordomus_cli::pack::{plan, ShardPlan};
503/// # use std::process::Command;
504/// # let dir = tempfile::tempdir().unwrap();
505/// # let (root, share) = (dir.path().join("repo"), dir.path().join("share"));
506/// # std::fs::create_dir_all(&root).unwrap();
507/// # std::fs::create_dir_all(&share).unwrap();
508/// # std::fs::write(share.join("archive.yaml"), "default: chat\nprofiles:\n - id: chat\n binary: drop\n artifacts: drop\n shards:\n max_count: 5\n max_tokens: 4000\n - id: bare\n binary: drop\n").unwrap();
509/// # let git = |a: &[&str]| assert!(Command::new("git").arg("-C").arg(&root)
510/// # .args(["-c", "user.email=t@example.com", "-c", "user.name=t"]).args(a)
511/// # .status().unwrap().success());
512/// # git(&["init", "-q"]);
513/// # std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
514/// # std::fs::write(root.join("logo.png"), b"\x89PNG\0\x01").unwrap();
515/// # git(&["add", "-A"]);
516/// # git(&["commit", "-q", "-m", "one"]);
517/// let planned = plan(&root, &share, None);
518/// let first: &ShardPlan = &planned.plan.shards[0];
519/// assert_eq!((first.files, first.first.as_str(), first.last.as_str()), (1, "main.rs", "main.rs"));
520/// assert!(first.file.ends_with(".md") && first.tokens > 0);
521/// ```
522#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
523pub struct ShardPlan {
524 /// The file name inside the pack.
525 pub file: String,
526 /// How many files it carries.
527 pub files: usize,
528 /// The first path it carries.
529 pub first: String,
530 /// The last path it carries.
531 pub last: String,
532 /// The bytes of the content it carries.
533 pub bytes: u64,
534 /// The tokens of the blocks it carries.
535 pub tokens: u64,
536}
537
538/// One dropped file the report names. Derived files are counted, not listed: they are a
539/// function of files that are in the pack.
540///
541/// The examples below plan a repository holding `main.rs` and a `logo.png` whose content is
542/// binary, with a distribution whose `chat` profile has limits and whose `bare` one has none.
543///
544/// ```
545/// use majordomus_cli::pack::{plan, DropReason, DroppedFile};
546/// # use std::process::Command;
547/// # let dir = tempfile::tempdir().unwrap();
548/// # let (root, share) = (dir.path().join("repo"), dir.path().join("share"));
549/// # std::fs::create_dir_all(&root).unwrap();
550/// # std::fs::create_dir_all(&share).unwrap();
551/// # std::fs::write(share.join("archive.yaml"), "default: chat\nprofiles:\n - id: chat\n binary: drop\n artifacts: drop\n shards:\n max_count: 5\n max_tokens: 4000\n - id: bare\n binary: drop\n").unwrap();
552/// # let git = |a: &[&str]| assert!(Command::new("git").arg("-C").arg(&root)
553/// # .args(["-c", "user.email=t@example.com", "-c", "user.name=t"]).args(a)
554/// # .status().unwrap().success());
555/// # git(&["init", "-q"]);
556/// # std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
557/// # std::fs::write(root.join("logo.png"), b"\x89PNG\0\x01").unwrap();
558/// # git(&["add", "-A"]);
559/// # git(&["commit", "-q", "-m", "one"]);
560/// let dropped = plan(&root, &share, None).plan.dropped_files;
561/// let want = DroppedFile { path: "logo.png".into(), reason: DropReason::Binary };
562/// assert_eq!(dropped, [want]);
563/// ```
564#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
565pub struct DroppedFile {
566 /// The path.
567 pub path: String,
568 /// Why.
569 pub reason: DropReason,
570}
571
572/// A dropped file is listed by its path; the path is unique in the index, so it is both the
573/// label and the identity.
574impl Ordered for DroppedFile {
575 fn order_key(&self) -> OrderKey<'_> {
576 OrderKey::plain(&self.path, &self.path)
577 }
578}
579
580/// A reason the pack cannot be built as planned, or a written pack is not what its
581/// manifest says: a stable code, the path when there is one, and the remedy.
582///
583/// The examples below plan a repository holding `main.rs` and a `logo.png` whose content is
584/// binary, with a distribution whose `chat` profile has limits and whose `bare` one has none.
585///
586/// ```
587/// use majordomus_cli::pack::{plan, PackFinding};
588/// # use std::process::Command;
589/// # let dir = tempfile::tempdir().unwrap();
590/// # let (root, share) = (dir.path().join("repo"), dir.path().join("share"));
591/// # std::fs::create_dir_all(&root).unwrap();
592/// # std::fs::create_dir_all(&share).unwrap();
593/// # std::fs::write(share.join("archive.yaml"), "default: chat\nprofiles:\n - id: chat\n binary: drop\n artifacts: drop\n shards:\n max_count: 5\n max_tokens: 4000\n - id: bare\n binary: drop\n").unwrap();
594/// # let git = |a: &[&str]| assert!(Command::new("git").arg("-C").arg(&root)
595/// # .args(["-c", "user.email=t@example.com", "-c", "user.name=t"]).args(a)
596/// # .status().unwrap().success());
597/// # git(&["init", "-q"]);
598/// # std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
599/// # std::fs::write(root.join("logo.png"), b"\x89PNG\0\x01").unwrap();
600/// # git(&["add", "-A"]);
601/// # git(&["commit", "-q", "-m", "one"]);
602/// let findings: Vec<PackFinding> = plan(&root, &share, Some("bare")).plan.findings;
603/// assert_eq!(findings[0].code, "pack.no_limits");
604/// assert!(findings[0].path.is_none() && !findings[0].remedy.is_empty());
605/// ```
606#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
607pub struct PackFinding {
608 /// A stable code: `pack.file_too_large`, `pack.too_many_shards`, `pack.index_too_large`, `pack.leak`,
609 /// `pack.empty`, `pack.no_limits`, and from verification `pack.tampered`,
610 /// `pack.not_text`, `pack.forbidden_path`, `pack.missing`, `pack.stray`,
611 /// `pack.over_budget`, `pack.manifest`.
612 pub code: String,
613 /// The path the finding is about, when it is about one.
614 pub path: Option<String>,
615 /// What is wrong.
616 pub message: String,
617 /// What settles it.
618 pub remedy: String,
619}
620
621impl PackFinding {
622 fn new(code: &str, path: Option<&str>, message: String, remedy: &str) -> Self {
623 PackFinding {
624 code: code.into(),
625 path: path.map(str::to_string),
626 message,
627 remedy: remedy.into(),
628 }
629 }
630}
631
632/// The plan of one pack: what it carries, what it leaves out and why, how it is cut into
633/// shards, and whether it may be built.
634///
635/// The examples below plan a repository holding `main.rs` and a `logo.png` whose content is
636/// binary, with a distribution whose `chat` profile has limits and whose `bare` one has none.
637///
638/// ```
639/// use majordomus_cli::pack::{plan, PackPlan};
640/// # use std::process::Command;
641/// # let dir = tempfile::tempdir().unwrap();
642/// # let (root, share) = (dir.path().join("repo"), dir.path().join("share"));
643/// # std::fs::create_dir_all(&root).unwrap();
644/// # std::fs::create_dir_all(&share).unwrap();
645/// # std::fs::write(share.join("archive.yaml"), "default: chat\nprofiles:\n - id: chat\n binary: drop\n artifacts: drop\n shards:\n max_count: 5\n max_tokens: 4000\n - id: bare\n binary: drop\n").unwrap();
646/// # let git = |a: &[&str]| assert!(Command::new("git").arg("-C").arg(&root)
647/// # .args(["-c", "user.email=t@example.com", "-c", "user.name=t"]).args(a)
648/// # .status().unwrap().success());
649/// # git(&["init", "-q"]);
650/// # std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
651/// # std::fs::write(root.join("logo.png"), b"\x89PNG\0\x01").unwrap();
652/// # git(&["add", "-A"]);
653/// # git(&["commit", "-q", "-m", "one"]);
654/// let p: PackPlan = plan(&root, &share, None).plan;
655/// assert!(p.measured && p.passes && p.index_matches_head);
656/// assert_eq!((p.tracked, p.selected), (2, 1));
657/// assert_eq!(p.dropped["binary"], 1);
658/// // an unknown profile is not measured, and not measured is never a pass
659/// let p: PackPlan = plan(&root, &share, Some("nope")).plan;
660/// assert!(!p.measured && !p.passes);
661/// ```
662#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
663pub struct PackPlan {
664 /// Whether the tree could be read at all. `false` is not a pass.
665 pub measured: bool,
666 /// Why it could not, when it could not.
667 pub reason: Option<String>,
668 /// The profile planned.
669 pub profile: String,
670 /// The commit `HEAD` names; the content is the index's, which equals it when
671 /// `index_matches_head` is true.
672 pub commit: Option<String>,
673 /// Whether the index holds exactly what `HEAD` holds. `false` means staged changes
674 /// travel with the pack under a commit id that does not contain them.
675 pub index_matches_head: bool,
676 /// How many files the index tracks.
677 pub tracked: usize,
678 /// How many the pack carries.
679 pub selected: usize,
680 /// The bytes of the content carried.
681 pub bytes: u64,
682 /// The tokens of the content carried, in `o200k_base`.
683 pub tokens: u64,
684 /// The tokenizer the counts are in.
685 pub tokenizer: String,
686 /// The limits applied.
687 pub limits: Option<ShardLimits>,
688 /// How many files were left out, by reason.
689 pub dropped: BTreeMap<String, usize>,
690 /// Every file left out for a reason other than `derived`.
691 pub dropped_files: Vec<DroppedFile>,
692 /// The shards, in order; the index is not one of them.
693 pub shards: Vec<ShardPlan>,
694 /// Every reason the pack cannot be built. Empty is the passing answer.
695 pub findings: Vec<PackFinding>,
696 /// `true` when the tree was measured and nothing was found.
697 pub passes: bool,
698}
699
700impl PackPlan {
701 fn unmeasured(profile: &str, reason: String) -> PackPlan {
702 PackPlan {
703 measured: false,
704 reason: Some(reason),
705 profile: profile.into(),
706 commit: None,
707 index_matches_head: false,
708 tracked: 0,
709 selected: 0,
710 bytes: 0,
711 tokens: 0,
712 tokenizer: crate::economics::context::TOKENIZER_ENCODING.into(),
713 limits: None,
714 dropped: BTreeMap::new(),
715 dropped_files: Vec::new(),
716 shards: Vec::new(),
717 findings: Vec::new(),
718 passes: false,
719 }
720 }
721}
722
723/// A plan with the content it was made from, which is what [`build`] writes: the verdict, the
724/// profile it was made with, and the selected blobs in shard order.
725///
726/// The examples below plan a repository holding `main.rs` and a `logo.png` whose content is
727/// binary, with a distribution whose `chat` profile has limits and whose `bare` one has none.
728///
729/// ```
730/// use majordomus_cli::pack::{plan, Planned};
731/// # use std::process::Command;
732/// # let dir = tempfile::tempdir().unwrap();
733/// # let (root, share) = (dir.path().join("repo"), dir.path().join("share"));
734/// # std::fs::create_dir_all(&root).unwrap();
735/// # std::fs::create_dir_all(&share).unwrap();
736/// # std::fs::write(share.join("archive.yaml"), "default: chat\nprofiles:\n - id: chat\n binary: drop\n artifacts: drop\n shards:\n max_count: 5\n max_tokens: 4000\n - id: bare\n binary: drop\n").unwrap();
737/// # let git = |a: &[&str]| assert!(Command::new("git").arg("-C").arg(&root)
738/// # .args(["-c", "user.email=t@example.com", "-c", "user.name=t"]).args(a)
739/// # .status().unwrap().success());
740/// # git(&["init", "-q"]);
741/// # std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
742/// # std::fs::write(root.join("logo.png"), b"\x89PNG\0\x01").unwrap();
743/// # git(&["add", "-A"]);
744/// # git(&["commit", "-q", "-m", "one"]);
745/// let planned: Planned = plan(&root, &share, Some("chat"));
746/// assert_eq!(planned.profile.id, "chat");
747/// assert_eq!(planned.plan.profile, planned.profile.id);
748/// ```
749pub struct Planned {
750 /// The verdict and the shape.
751 pub plan: PackPlan,
752 /// The profile it was planned with.
753 pub profile: PackProfile,
754 /// The profiles it was chosen from, which verify what [`build`] writes; empty when the
755 /// profiles could not be read.
756 pub profiles: Profiles,
757 /// The selected files, in shard order, with their shard's index into `plan.shards`.
758 pub(crate) files: Vec<(usize, Selected)>,
759}
760
761/// Plan a pack of the repository at `root` with a profile of the distribution at `share`:
762/// the index's blobs, selected by the profile, cut into shards and judged. Nothing is
763/// written; a tree or a profile that cannot be read is an unmeasured plan, never a pass.
764///
765/// The examples below plan a repository holding `main.rs` and a `logo.png` whose content is
766/// binary, with a distribution whose `chat` profile has limits and whose `bare` one has none.
767///
768/// ```
769/// use majordomus_cli::pack::plan;
770/// # use std::process::Command;
771/// # let dir = tempfile::tempdir().unwrap();
772/// # let (root, share) = (dir.path().join("repo"), dir.path().join("share"));
773/// # std::fs::create_dir_all(&root).unwrap();
774/// # std::fs::create_dir_all(&share).unwrap();
775/// # std::fs::write(share.join("archive.yaml"), "default: chat\nprofiles:\n - id: chat\n binary: drop\n artifacts: drop\n shards:\n max_count: 5\n max_tokens: 4000\n - id: bare\n binary: drop\n").unwrap();
776/// # let git = |a: &[&str]| assert!(Command::new("git").arg("-C").arg(&root)
777/// # .args(["-c", "user.email=t@example.com", "-c", "user.name=t"]).args(a)
778/// # .status().unwrap().success());
779/// # git(&["init", "-q"]);
780/// # std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
781/// # std::fs::write(root.join("logo.png"), b"\x89PNG\0\x01").unwrap();
782/// # git(&["add", "-A"]);
783/// # git(&["commit", "-q", "-m", "one"]);
784/// let planned = plan(&root, &share, None);
785/// assert!(planned.plan.passes, "{:?}", planned.plan.findings);
786/// assert_eq!(planned.plan.profile, "chat");
787/// assert_eq!(planned.plan.shards.len(), 1);
788/// ```
789pub fn plan(root: &Path, share: &Path, profile: Option<&str>) -> Planned {
790 let label = profile.unwrap_or("").to_string();
791 let profiles = match Profiles::load(share, root) {
792 Ok(p) => p,
793 Err(e) => return unmeasured(&label, e),
794 };
795 let chosen = match profiles.profile(profile) {
796 Ok(p) => p.clone(),
797 Err(e) => return unmeasured(&label, e),
798 };
799 plan_with(root, &profiles, &chosen).unwrap_or_else(|e| unmeasured(&chosen.id, e))
800}
801
802/// A plan that could not be made, with the reason: never a pass, and never built.
803pub(crate) fn unmeasured(profile: &str, reason: String) -> Planned {
804 Planned {
805 plan: PackPlan::unmeasured(profile, reason),
806 profile: PackProfile {
807 id: profile.into(),
808 title: String::new(),
809 derived: String::new(),
810 binary: String::new(),
811 artifacts: String::new(),
812 exclude: Vec::new(),
813 include: Vec::new(),
814 orientation: Vec::new(),
815 shards: None,
816 },
817 profiles: Profiles::default(),
818 files: Vec::new(),
819 }
820}
821
822/// Plan with profiles already read: the body of [`plan`], for a caller that holds the
823/// profiles and the chosen one.
824pub(crate) fn plan_with(
825 root: &Path,
826 profiles: &Profiles,
827 profile: &PackProfile,
828) -> Result<Planned, String> {
829 let (entries, derived) = index_entries(root, profile.derived == "drop")?;
830 let exclude: Vec<Glob> = profile.exclude.iter().map(|g| Glob::new(g)).collect();
831 let include: Vec<Glob> = profile.include.iter().map(|g| Glob::new(g)).collect();
832 let artifacts = profile.artifacts == "drop";
833 let binary = profile.binary == "drop";
834
835 let mut dropped: BTreeMap<String, usize> = BTreeMap::new();
836 let mut dropped_files = Vec::new();
837 let mut kept = Vec::new();
838 let mut drop = |path: &str, reason: DropReason, dropped: &mut BTreeMap<String, usize>| {
839 *dropped.entry(reason.as_str().to_string()).or_insert(0) += 1;
840 if reason != DropReason::Derived {
841 dropped_files.push(DroppedFile {
842 path: path.to_string(),
843 reason,
844 });
845 }
846 };
847 for e in &entries {
848 let reason = if artifacts && e.mode == "160000" {
849 Some(DropReason::Worktree)
850 } else if artifacts && e.mode == "120000" {
851 Some(DropReason::Link)
852 } else if artifacts && profiles.is_artifact(&e.path) {
853 Some(DropReason::Artifact)
854 } else if binary && profiles.has_binary_extension(&e.path) {
855 Some(DropReason::Binary)
856 } else if derived.contains(&e.path) {
857 Some(DropReason::Derived)
858 } else if exclude.iter().any(|g| g.matches(&e.path)) {
859 Some(DropReason::Excluded)
860 } else {
861 None
862 };
863 let reason =
864 reason.filter(|r| !(r.reincludable() && include.iter().any(|g| g.matches(&e.path))));
865 // a gitlink names a commit, not a blob: there is nothing to read, so one that is
866 // kept (a profile that keeps artifacts) is still left out, and said so
867 match reason {
868 Some(r) => drop(&e.path, r, &mut dropped),
869 None if e.mode == "160000" => drop(&e.path, DropReason::Worktree, &mut dropped),
870 None => kept.push(e.clone()),
871 }
872 }
873
874 let commit = head(root);
875 let blobs = read_blobs(root, &kept)?;
876 let machine = machine_paths(root);
877 let mut findings = Vec::new();
878 let mut selected = Vec::new();
879 for (e, content) in kept.into_iter().zip(blobs) {
880 if binary && !is_text(&content) {
881 drop(&e.path, DropReason::Binary, &mut dropped);
882 continue;
883 }
884 let Ok(text) = String::from_utf8(content) else {
885 // a profile that keeps binaries cannot carry one in a text pack either
886 drop(&e.path, DropReason::Binary, &mut dropped);
887 continue;
888 };
889 if let Some(line) = names_machine(&text, &machine) {
890 findings.push(PackFinding::new(
891 "pack.leak",
892 Some(&e.path),
893 format!("line {line} names this machine: the checkout's path or the account's home directory"),
894 "write what the path means rather than the path, commit, and plan again; a pack never carries it",
895 ));
896 }
897 let tokens = shard::block_tokens(&e, &text);
898 selected.push(Selected {
899 entry: e,
900 text,
901 tokens,
902 });
903 }
904
905 let limits = profile.shards;
906 let mut shards = Vec::new();
907 let mut files = Vec::new();
908 match limits {
909 None => findings.push(PackFinding::new(
910 "pack.no_limits",
911 None,
912 format!("profile '{}' declares no `shards:` limits", profile.id),
913 "add `shards: {max_count, max_tokens}` to the profile in share/archive.yaml, or pack with a profile that has them",
914 )),
915 Some(limits) => {
916 let budget = limits.max_tokens * FILL_PERCENT / 100;
917 for s in selected {
918 if s.tokens > budget {
919 findings.push(PackFinding::new(
920 "pack.file_too_large",
921 Some(&s.entry.path),
922 format!(
923 "{} tokens, more than one shard's budget of {budget}",
924 s.tokens
925 ),
926 "exclude the file in the profile, or raise `shards.max_tokens` if the reader accepts it",
927 ));
928 continue;
929 }
930 let open = shards
931 .last()
932 .is_some_and(|sh: &ShardPlan| sh.tokens + s.tokens <= budget);
933 if !open {
934 shards.push(ShardPlan {
935 file: String::new(),
936 files: 0,
937 first: s.entry.path.clone(),
938 last: String::new(),
939 bytes: 0,
940 tokens: shard::header_tokens(&s.entry.path, commit.as_deref(), &profile.id),
941 });
942 }
943 let i = shards.len() - 1;
944 let sh = &mut shards[i];
945 sh.files += 1;
946 sh.last = s.entry.path.clone();
947 sh.bytes += s.text.len() as u64;
948 sh.tokens += s.tokens;
949 files.push((i, s));
950 }
951 for (i, sh) in shards.iter_mut().enumerate() {
952 sh.file = shard::shard_name(i + 1, &sh.first);
953 }
954 if shards.len() + 1 > limits.max_count {
955 findings.push(PackFinding::new(
956 "pack.too_many_shards",
957 None,
958 format!(
959 "{} shard(s) and the index, more than the {} file(s) the profile allows",
960 shards.len(),
961 limits.max_count
962 ),
963 "exclude more of the tree in the profile, or raise `shards.max_tokens` so fewer shards hold it",
964 ));
965 }
966 }
967 }
968 if files.is_empty() && limits.is_some() {
969 findings.push(PackFinding::new(
970 "pack.empty",
971 None,
972 "the profile selects no file".into(),
973 "a pack of nothing is not a pack; check the profile's exclude list",
974 ));
975 }
976
977 let bytes = files.iter().map(|(_, s)| s.text.len() as u64).sum();
978 let tokens = files.iter().map(|(_, s)| s.tokens).sum();
979 canonical(&mut dropped_files);
980 let mut plan = PackPlan {
981 measured: true,
982 reason: None,
983 profile: profile.id.clone(),
984 commit,
985 index_matches_head: index_matches_head(root),
986 tracked: entries.len(),
987 selected: files.len(),
988 bytes,
989 tokens,
990 tokenizer: crate::economics::context::TOKENIZER_ENCODING.into(),
991 limits,
992 dropped,
993 dropped_files,
994 shards,
995 passes: findings.is_empty(),
996 findings,
997 };
998 // the index is a file of the pack like any shard, and is held to the same budget
999 if let Some(l) = limits.filter(|_| plan.passes) {
1000 let tokens = shard::index_tokens(profile, &plan);
1001 if tokens > l.max_tokens * FILL_PERCENT / 100 {
1002 plan.findings.push(PackFinding::new(
1003 "pack.index_too_large",
1004 Some(INDEX),
1005 format!("the index would hold {tokens} tokens, more than one file's budget"),
1006 "exclude whole directories rather than single files, so fewer files are listed as left out",
1007 ));
1008 plan.passes = false;
1009 }
1010 }
1011 Ok(Planned {
1012 plan,
1013 profile: profile.clone(),
1014 profiles: profiles.clone(),
1015 files,
1016 })
1017}
1018
1019/// Is `home` the home of a hosted CI runner, as the repository's one definition of that
1020/// shape says (`runner-path` in `src/quality/leak-patterns.tsv`)? Such a home names no
1021/// person's machine, and the definition itself, which the pack carries, spells it out.
1022fn is_runner_home(home: &Path) -> bool {
1023 let text = format!("{}/", home.display());
1024 crate::quality::leaks::Leaks::shipped()
1025 .map(|l| {
1026 !l.scan(
1027 text.as_bytes(),
1028 &[crate::quality::leaks::LeakClass::RunnerPath],
1029 )
1030 .is_empty()
1031 })
1032 .unwrap_or(false)
1033}
1034
1035/// The first line of `text` naming one of `machine`, 1-based.
1036pub(crate) fn names_machine(text: &str, machine: &[String]) -> Option<usize> {
1037 if !machine.iter().any(|m| text.contains(m.as_str())) {
1038 return None;
1039 }
1040 text.lines()
1041 .position(|l| machine.iter().any(|m| l.contains(m.as_str())))
1042 .map(|n| n + 1)
1043}
1044
1045/// Run git in `root` with `input` on its standard input and answer what it printed. A git
1046/// that cannot be started (a root that does not exist) and a git that fails are both an
1047/// error naming the subcommand, so every read below has one way to fail.
1048fn git_output(root: &Path, args: &[&str], input: Vec<u8>) -> Result<Vec<u8>, String> {
1049 // absolute, so that `-C` and the working directory name the same place; a root with no
1050 // absolute form becomes the empty path, which no process can be started in
1051 let root = std::path::absolute(root).unwrap_or_default();
1052 let run = crate::git::read_only(&root)
1053 .current_dir(&root)
1054 .args(args)
1055 .stdin(Stdio::piped())
1056 .stdout(Stdio::piped())
1057 .stderr(Stdio::piped())
1058 .spawn()
1059 .and_then(|mut child| {
1060 let writer = child
1061 .stdin
1062 .take()
1063 .map(|mut stdin| std::thread::spawn(move || stdin.write_all(&input)));
1064 let out = child.wait_with_output();
1065 let _ = writer.map(std::thread::JoinHandle::join);
1066 out
1067 });
1068 match run {
1069 Ok(out) if out.status.success() => Ok(out.stdout),
1070 Ok(out) => Err(format!(
1071 "git {}: {}",
1072 args[0],
1073 String::from_utf8_lossy(&out.stderr).trim()
1074 )),
1075 Err(e) => Err(format!("cannot run git {}: {e}", args[0])),
1076 }
1077}
1078
1079/// Every entry of the index in git's order, and, when `derived` is asked, the paths
1080/// `.gitattributes` marks `merge=derived`, asked of git itself.
1081fn index_entries(
1082 root: &Path,
1083 derived: bool,
1084) -> Result<(Vec<IndexEntry>, BTreeSet<String>), String> {
1085 git_output(root, &["ls-files", "-s", "-z"], Vec::new())
1086 .and_then(|out| parse_index(&out))
1087 .and_then(|entries| {
1088 if derived {
1089 derived_paths(root, &entries).map(|d| (entries, d))
1090 } else {
1091 Ok((entries, BTreeSet::new()))
1092 }
1093 })
1094}
1095
1096/// The records of `git ls-files -s -z`: `<mode> <oid> <stage>\t<path>`. A record that is not
1097/// a stage-0 entry is a conflict nobody resolved, and a pack of it would carry one side.
1098fn parse_index(out: &[u8]) -> Result<Vec<IndexEntry>, String> {
1099 let mut entries = Vec::new();
1100 for record in out.split(|b| *b == 0).filter(|r| !r.is_empty()) {
1101 let record = String::from_utf8_lossy(record);
1102 let (meta, path) = record.split_once('\t').unwrap_or((&record, ""));
1103 let mut parts = meta.splitn(3, ' ');
1104 let (mode, oid, stage) = (
1105 parts.next().unwrap_or_default(),
1106 parts.next().unwrap_or_default(),
1107 parts.next().unwrap_or_default(),
1108 );
1109 if stage != "0" {
1110 return Err(format!(
1111 "{path} is unmerged in the index; resolve the conflict before packing"
1112 ));
1113 }
1114 entries.push(IndexEntry {
1115 mode: mode.into(),
1116 oid: oid.into(),
1117 path: path.into(),
1118 });
1119 }
1120 Ok(entries)
1121}
1122
1123/// The paths `.gitattributes` marks `merge=derived`, asked of git itself.
1124fn derived_paths(root: &Path, entries: &[IndexEntry]) -> Result<BTreeSet<String>, String> {
1125 let paths: Vec<u8> = entries
1126 .iter()
1127 .flat_map(|e| e.path.bytes().chain(std::iter::once(0)))
1128 .collect();
1129 git_output(root, &["check-attr", "-z", "--stdin", "merge"], paths).map(|out| {
1130 // "<path>\0merge\0<value>\0" per path
1131 let fields: Vec<&[u8]> = out.split(|b| *b == 0).collect();
1132 fields
1133 .chunks(3)
1134 .filter(|c| c.len() == 3 && c[2] == b"derived")
1135 .map(|c| String::from_utf8_lossy(c[0]).into_owned())
1136 .collect()
1137 })
1138}
1139
1140/// The content of every entry's blob, in order, through one `git cat-file --batch`.
1141fn read_blobs(root: &Path, entries: &[IndexEntry]) -> Result<Vec<Vec<u8>>, String> {
1142 let request: Vec<u8> = entries
1143 .iter()
1144 .flat_map(|e| e.oid.bytes().chain(std::iter::once(b'\n')))
1145 .collect();
1146 git_output(root, &["cat-file", "--batch"], request).and_then(|out| split_blobs(&out, entries))
1147}
1148
1149/// The objects of a `git cat-file --batch` answer, one per entry: `<oid> blob <size>\n`,
1150/// the content and a newline. Anything else — a missing object, an answer cut short — is
1151/// named by the entry it was the answer for.
1152fn split_blobs(out: &[u8], entries: &[IndexEntry]) -> Result<Vec<Vec<u8>>, String> {
1153 let mut blobs = Vec::with_capacity(entries.len());
1154 let mut at = 0;
1155 for e in entries {
1156 let rest = out.get(at..).unwrap_or_default();
1157 let nl = rest.iter().position(|b| *b == b'\n').unwrap_or(rest.len());
1158 let header = String::from_utf8_lossy(&rest[..nl]).into_owned();
1159 let start = at + nl + 1;
1160 let end = header
1161 .strip_prefix(&format!("{} blob ", e.oid))
1162 .and_then(|size| size.parse::<usize>().ok())
1163 .map(|size| start + size)
1164 .filter(|end| *end <= out.len());
1165 let Some(end) = end else {
1166 return Err(format!("{}: git cat-file answered '{header}'", e.path));
1167 };
1168 blobs.push(out[start..end].to_vec());
1169 at = end + 1;
1170 }
1171 Ok(blobs)
1172}
1173
1174fn head(root: &Path) -> Option<String> {
1175 git_output(root, &["rev-parse", "--verify", "-q", "HEAD"], Vec::new())
1176 .ok()
1177 .map(|out| String::from_utf8_lossy(&out).trim().to_string())
1178}
1179
1180fn index_matches_head(root: &Path) -> bool {
1181 git_output(
1182 root,
1183 &["diff", "--cached", "--quiet", "HEAD", "--"],
1184 Vec::new(),
1185 )
1186 .is_ok()
1187}
1188
1189/// Where a pack of `plan` is written when no directory is named: under the repository's
1190/// ignored `tmp/`, named for the repository, the profile and the commit. The repository's
1191/// name is its primary checkout's, which a linked worktree shares: a worktree's directory
1192/// is named for its branch.
1193pub(crate) fn default_out(root: &Path, plan: &PackPlan) -> PathBuf {
1194 let common = git_output(
1195 root,
1196 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
1197 Vec::new(),
1198 )
1199 .ok()
1200 .map(|o| PathBuf::from(String::from_utf8_lossy(&o).trim()));
1201 let named = common
1202 .as_deref()
1203 .filter(|c| c.file_name().is_some_and(|n| n == ".git"))
1204 .and_then(Path::parent)
1205 .unwrap_or(root);
1206 let repo = named
1207 .file_name()
1208 .unwrap_or(std::ffi::OsStr::new("repository"))
1209 .to_string_lossy();
1210 let short: String = plan
1211 .commit
1212 .as_deref()
1213 .unwrap_or("uncommitted")
1214 .chars()
1215 .take(12)
1216 .collect();
1217 root.join("tmp")
1218 .join("packs")
1219 .join(format!("{repo}-{}-{short}", plan.profile))
1220}
1221
1222/// Write a planned pack into `out`, which must be absent, empty, or a pack this command
1223/// wrote (it holds a manifest); then verify what was written.
1224///
1225/// The examples below plan a repository holding `main.rs` and a `logo.png` whose content is
1226/// binary, with a distribution whose `chat` profile has limits and whose `bare` one has none.
1227///
1228/// ```
1229/// use majordomus_cli::pack::{build, plan, INDEX, MANIFEST};
1230/// # use std::process::Command;
1231/// # let dir = tempfile::tempdir().unwrap();
1232/// # let (root, share) = (dir.path().join("repo"), dir.path().join("share"));
1233/// # std::fs::create_dir_all(&root).unwrap();
1234/// # std::fs::create_dir_all(&share).unwrap();
1235/// # std::fs::write(share.join("archive.yaml"), "default: chat\nprofiles:\n - id: chat\n binary: drop\n artifacts: drop\n shards:\n max_count: 5\n max_tokens: 4000\n - id: bare\n binary: drop\n").unwrap();
1236/// # let git = |a: &[&str]| assert!(Command::new("git").arg("-C").arg(&root)
1237/// # .args(["-c", "user.email=t@example.com", "-c", "user.name=t"]).args(a)
1238/// # .status().unwrap().success());
1239/// # git(&["init", "-q"]);
1240/// # std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
1241/// # std::fs::write(root.join("logo.png"), b"\x89PNG\0\x01").unwrap();
1242/// # git(&["add", "-A"]);
1243/// # git(&["commit", "-q", "-m", "one"]);
1244/// let out = dir.path().join("pack");
1245/// let verdict = build(&plan(&root, &share, None), &root, &out).unwrap();
1246/// assert!(verdict.passes, "{:?}", verdict.findings);
1247/// assert!(out.join(INDEX).is_file() && out.join(MANIFEST).is_file());
1248/// // a plan with a finding is never written
1249/// let refused = build(&plan(&root, &share, Some("bare")), &root, &out);
1250/// assert!(refused.is_err());
1251/// ```
1252pub fn build(planned: &Planned, root: &Path, out: &Path) -> Result<PackVerdict, String> {
1253 if !planned.plan.passes {
1254 return Err("the plan has findings; a pack with a finding is not built".into());
1255 }
1256 if out.exists() {
1257 let names: Vec<String> = std::fs::read_dir(out)
1258 .map_err(|e| format!("cannot read {}: {e}", out.display()))?
1259 .flatten()
1260 .map(|e| e.file_name().to_string_lossy().into_owned())
1261 .collect();
1262 // every name is judged before anything is removed, so a refusal removes nothing
1263 if !names.is_empty() && !out.join(MANIFEST).is_file() {
1264 return Err(format!(
1265 "{} is not empty and holds no {MANIFEST}: it is not a pack, and nothing in it is removed",
1266 out.display()
1267 ));
1268 }
1269 if let Some(name) = names.iter().find(|n| *n != MANIFEST && !n.ends_with(".md")) {
1270 return Err(format!(
1271 "{} holds {name}, which no pack writes; remove it by hand",
1272 out.display()
1273 ));
1274 }
1275 for name in &names {
1276 std::fs::remove_file(out.join(name))
1277 .map_err(|e| format!("cannot replace {name}: {e}"))?;
1278 }
1279 }
1280 std::fs::create_dir_all(out).map_err(|e| format!("cannot create {}: {e}", out.display()))?;
1281 shard::write(planned, out)?;
1282 Ok(verify(out, &planned.profiles, root))
1283}
1284
1285#[cfg(test)]
1286mod tests;