choir_node/portable.rs
1//! Exporting a node root, importing one, and settling what an export
2//! claims (§E).
3//!
4//! The plan's standing rule for a one-way door is that no persisted
5//! format ships without three things: a version field, a written
6//! evolution policy, and export/import tooling. The op log has had the
7//! first two since its first byte — every entry carries
8//! `format_version`, and [`crate::platform`] documents the additive
9//! rule. This is the third, and it is deliberately a *format* tool
10//! rather than a deployment one.
11//!
12//! That distinction is the reason this exists beside `scripts/pull_backup.sh`
13//! instead of replacing it. That script is disaster recovery for one
14//! deployment: it pulls over ssh from a fixed remote path, refuses to run
15//! on the machine that holds the log, takes its repository list from a
16//! policy file, needs python3 to project a hash, and checks the bundles
17//! against the D25 attestation. All of that is right for the job it does
18//! and none of it travels. Here there is no network, no ssh key, no
19//! `repos.list`, and no interpreter: a directory goes in, a directory
20//! comes out, and the log itself says what the export must contain.
21//!
22//! # What an export claims, and how the claim is settled
23//!
24//! An export is not a pile of files, it is an assertion with two halves
25//! that can disagree: *this op log* and *these repositories* describe the
26//! same node. [`verify`] settles it by folding the log into a
27//! [`choir_view::View`] and requiring every ref the view names to be
28//! present in that repository's bundle at the same oid.
29//!
30//! The check is deliberately one-directional. A bundle may carry refs the
31//! log does not name; a log may not name refs no bundle carries. That
32//! asymmetry is what makes exporting a *running* node meaningful: the log
33//! is read first and the bundles after, so a push landing mid-export puts
34//! the bundles ahead, which is a state the export can honestly describe.
35//! The reverse — the log naming a commit no bundle holds — is the failure
36//! that matters, because restoring it produces a node whose view points
37//! at objects it does not have, and startup reconciliation answers that
38//! by appending signed retractions of exactly the refs being restored.
39//! [`Report::ahead`] counts the benign direction rather than hiding it,
40//! so an operator can see drift instead of inferring it.
41//!
42//! # What an export does not contain
43//!
44//! Secrets, by construction and then by inspection. Only four names are
45//! ever copied out of `.choir`, and the finished directory is walked
46//! again afterwards and refused if it holds anything matching
47//! [`is_secret`] — so the guarantee is a property of the output rather
48//! than of the care taken while writing it. The node's signing key stays
49//! with the node that owns it, and `docs/runbook-restore.md` covers what
50//! its absence means.
51//!
52//! Policy files are also absent, and that is not an oversight worth
53//! quietly tolerating: `--acl-file`, `--auth-file`, `--keys-file` and the
54//! rest name paths anywhere on the host, so a root does not know where
55//! they are and an export taken from one cannot honestly claim to hold
56//! them. The manifest says so in a field rather than leaving the reader
57//! to notice. Provisioned workspaces and checkouts under `.choir` are
58//! left behind too, being derived from refs the export does carry.
59
60use std::collections::BTreeMap;
61use std::path::{Path, PathBuf};
62
63/// Version of the export directory's own layout, carried in the manifest.
64///
65/// Separate from the op log's `format_version`, which the manifest also
66/// records: a change to how this directory is arranged is not a change to
67/// the entries inside it, and conflating the two would make either
68/// version bump imply the other.
69pub const FORMAT_VERSION: u32 = 1;
70
71/// Whether a file name is one an export must never carry.
72///
73/// Named rather than enumerated: `--tls-key`, a per-actor key and a
74/// GitHub App PEM are all secrets that no list of literal file names
75/// would keep up with, so the rule is the shape of the name. `auth` is
76/// the one literal, being the bearer-token table.
77#[must_use]
78pub fn is_secret(name: &str) -> bool {
79 name == "auth" || name.ends_with(".key") || name.ends_with(".pem")
80}
81
82/// What an export holds, counted from the files rather than claimed.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Report {
85 /// Op-log records the export carries.
86 pub records: u64,
87 /// The log's head hash as hex, or `None` for an empty log.
88 pub head: Option<String>,
89 /// Repositories the export carries a bundle for.
90 pub bundles: usize,
91 /// Refs the log names that a bundle carries at the same oid.
92 pub refs: usize,
93 /// Refs a bundle carries that the log does not name.
94 ///
95 /// Benign by itself — see the module docs on why the check runs in
96 /// one direction — and reported so that drift is visible.
97 pub ahead: usize,
98}
99
100/// Writes an export of `root` into `dest`, then verifies its own output.
101///
102/// `dest` must not already exist. An export never writes into a
103/// directory it did not create, for the same reason
104/// `scripts/restore_from_backup.sh` never writes over a log: the thing
105/// being overwritten is the fallback.
106///
107/// # Errors
108///
109/// Returns a description when `root` holds no op log, when that log does
110/// not verify, when a repository the log names refs for cannot be
111/// bundled, or when the finished directory fails [`verify`].
112pub fn export(root: &Path, dest: &Path) -> Result<Report, String> {
113 let state = root.join(".choir");
114 let log = state.join("ops.jsonl");
115 if !log.is_file() {
116 return Err(format!(
117 "{} holds no op log at .choir/ops.jsonl; a node started without \
118 --keys-file runs no sequencer and writes none",
119 root.display()
120 ));
121 }
122 if dest.exists() {
123 return Err(format!(
124 "{} already exists; an export writes a new directory rather than into one",
125 dest.display()
126 ));
127 }
128
129 // The log is read before a single bundle is taken, which is what
130 // puts a concurrent push in the benign direction rather than the
131 // failing one. See the module docs.
132 let chain = chain_report(&log)?;
133 let refs = log_refs(&log)?;
134
135 std::fs::create_dir_all(dest.join("repos")).map_err(|e| format!("create {dest:?}: {e}"))?;
136 copy(&log, &dest.join("ops.jsonl"))?;
137 for name in ["node.fingerprint", "refs.snapshot"] {
138 let from = state.join(name);
139 if from.is_file() {
140 copy(&from, &dest.join(name))?;
141 }
142 }
143
144 let mut rows = Vec::new();
145 for repo in repos(root)? {
146 let dir = root.join(&repo);
147 let bundle = dest.join("repos").join(format!("{repo}.bundle"));
148 if let Some(parent) = bundle.parent() {
149 std::fs::create_dir_all(parent).map_err(|e| format!("create {parent:?}: {e}"))?;
150 }
151 // A repository with no refs cannot be bundled: git refuses
152 // rather than writing a zero-ref bundle. Recording it as an
153 // empty row keeps the export's repository list complete, which
154 // is what lets an import recreate a repo somebody made and has
155 // not pushed to yet.
156 let bundled = !git(&["for-each-ref", "--format=%(refname)"], &dir)?
157 .trim()
158 .is_empty();
159 if bundled {
160 let path = bundle.to_str().ok_or("bundle path is not utf-8")?;
161 git(&["bundle", "create", path, "--all"], &dir)?;
162 }
163 rows.push((repo, bundled));
164 }
165
166 let manifest = serde_json::json!({
167 "format_version": FORMAT_VERSION,
168 "log_format_version": choir_oplog::FORMAT_VERSION,
169 "records": chain.0,
170 "head": chain.1,
171 "repos": rows.iter().map(|(name, bundled)| serde_json::json!({
172 "name": name,
173 "bundle": bundled.then(|| format!("repos/{name}.bundle")),
174 "refs": refs.get(name).map_or(0, BTreeMap::len),
175 })).collect::<Vec<_>>(),
176 "policy_files": "not included: named by flags and stored outside the root",
177 "secrets": "not included: see docs/runbook-restore.md",
178 });
179 std::fs::write(
180 dest.join("manifest.json"),
181 format!(
182 "{}\n",
183 serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?
184 ),
185 )
186 .map_err(|e| format!("write manifest: {e}"))?;
187
188 // An export that does not verify is not an export. Running the
189 // reader over the writer's output is the only thing that makes the
190 // two agree about the format a year from now.
191 verify(dest)
192}
193
194/// Settles what an export at `dir` claims: the log verifies, the manifest
195/// describes it, and every ref the log names is in a bundle at that oid.
196///
197/// # Errors
198///
199/// Returns a description naming the first disagreement found.
200pub fn verify(dir: &Path) -> Result<Report, String> {
201 let manifest: serde_json::Value = serde_json::from_slice(
202 &std::fs::read(dir.join("manifest.json"))
203 .map_err(|e| format!("{}: no manifest.json ({e})", dir.display()))?,
204 )
205 .map_err(|e| format!("manifest.json does not parse: {e}"))?;
206 let version = manifest
207 .get("format_version")
208 .and_then(serde_json::Value::as_u64);
209 if version != Some(u64::from(FORMAT_VERSION)) {
210 return Err(format!(
211 "export format_version is {}, this build reads {FORMAT_VERSION}",
212 version.map_or_else(|| "absent".to_string(), |v| v.to_string())
213 ));
214 }
215
216 let log = dir.join("ops.jsonl");
217 if !log.is_file() {
218 return Err("export holds no ops.jsonl".to_string());
219 }
220 let (records, head) = chain_report(&log)?;
221 if manifest.get("records").and_then(serde_json::Value::as_u64) != Some(records) {
222 return Err(format!(
223 "manifest claims {} records, the log verifies {records}",
224 manifest["records"]
225 ));
226 }
227 if manifest.get("head").and_then(serde_json::Value::as_str) != head.as_deref() {
228 return Err(format!(
229 "manifest claims head {}, the log ends at {}",
230 manifest["head"],
231 head.as_deref().unwrap_or("nothing")
232 ));
233 }
234
235 // Every name in the manifest, so that a repository dropped from the
236 // export is caught even when the log names no ref for it.
237 let listed = rows(&manifest)?;
238
239 let (mut matched, mut ahead, mut bundles) = (0usize, 0usize, 0usize);
240 let refs = log_refs(&log)?;
241 for (repo, wanted) in &refs {
242 if !listed.contains_key(repo) {
243 return Err(format!(
244 "the log names {} refs for {repo}, which the manifest does not list",
245 wanted.len()
246 ));
247 }
248 }
249 for (repo, bundled) in &listed {
250 let empty = BTreeMap::new();
251 let wanted = refs.get(repo).unwrap_or(&empty);
252 if !bundled {
253 if wanted.is_empty() {
254 continue;
255 }
256 return Err(format!(
257 "the log names {} refs for {repo}, which the export carries no bundle for",
258 wanted.len()
259 ));
260 }
261 bundles += 1;
262 let path = dir.join("repos").join(format!("{repo}.bundle"));
263 if !path.is_file() {
264 return Err(format!(
265 "{repo}: the manifest names a bundle that is not here"
266 ));
267 }
268 let check = check_refs(wanted, &bundle_heads(&path)?);
269 match check.disagreements.first() {
270 Some(RefDisagreement::Moved {
271 name,
272 wanted,
273 found,
274 }) => {
275 return Err(format!(
276 "{repo}: the log has {name} at {wanted}, the bundle has it at {found}"
277 ))
278 }
279 Some(RefDisagreement::Missing { name, wanted }) => {
280 return Err(format!(
281 "{repo}: the log names {name} at {wanted}, the bundle does not carry it"
282 ))
283 }
284 None => {}
285 }
286 matched += check.matched;
287 ahead += check.ahead;
288 }
289
290 for found in walk(dir)? {
291 let name = found
292 .file_name()
293 .map_or_else(String::new, |n| n.to_string_lossy().into_owned());
294 if is_secret(&name) {
295 return Err(format!(
296 "{} is a secret and an export must not carry one",
297 found.display()
298 ));
299 }
300 }
301
302 Ok(Report {
303 records,
304 head,
305 bundles,
306 refs: matched,
307 ahead,
308 })
309}
310
311/// Places the export at `dir` into a fresh node `root`.
312///
313/// Verifies before writing a byte, then refuses a root that already
314/// holds a log or any repository the manifest names — the thing being
315/// written over is the fallback, which is the same reason
316/// `scripts/restore_from_backup.sh` refuses both.
317///
318/// What this does *not* do is claim the result works. A restored node
319/// that serves is not a restored node; what settles it is one that
320/// accepts a write, and the secrets that boot needs are deliberately not
321/// here. `docs/runbook-restore.md` covers them. Hooks and git config are
322/// not written either: the daemon adopts a repository it finds under its
323/// root, so writing them here would be a second copy of that rule, drifting.
324///
325/// # Errors
326///
327/// Returns a description when the export does not verify, when `root`
328/// already holds a log or a named repository, or when git refuses to
329/// unbundle one.
330pub fn import(dir: &Path, root: &Path) -> Result<Report, String> {
331 let report = verify(dir)?;
332 let manifest: serde_json::Value = serde_json::from_slice(
333 &std::fs::read(dir.join("manifest.json")).map_err(|e| e.to_string())?,
334 )
335 .map_err(|e| e.to_string())?;
336 let listed = rows(&manifest)?;
337
338 let state = root.join(".choir");
339 if state.join("ops.jsonl").exists() {
340 return Err(format!(
341 "{} already holds a log: import into a fresh root, or move the existing \
342 log aside first, because nothing here overwrites one",
343 state.display()
344 ));
345 }
346 for name in listed.keys() {
347 if root.join(name).exists() {
348 return Err(format!(
349 "{} already exists: import into a fresh root",
350 root.join(name).display()
351 ));
352 }
353 }
354
355 std::fs::create_dir_all(&state).map_err(|e| format!("create {state:?}: {e}"))?;
356 copy(&dir.join("ops.jsonl"), &state.join("ops.jsonl"))?;
357 for name in ["node.fingerprint", "refs.snapshot"] {
358 let from = dir.join(name);
359 if from.is_file() {
360 copy(&from, &state.join(name))?;
361 }
362 }
363
364 for (name, bundled) in &listed {
365 let target = root.join(name);
366 let target = target.to_str().ok_or("repository path is not utf-8")?;
367 if *bundled {
368 let bundle = dir.join("repos").join(format!("{name}.bundle"));
369 let bundle = bundle.to_str().ok_or("bundle path is not utf-8")?;
370 git(
371 &["clone", "--bare", "--quiet", bundle, target],
372 Path::new("."),
373 )?;
374 // A bundle clone leaves an `origin` pointing at the bundle
375 // file, which will not be there once the export is gone.
376 git(&["remote", "remove", "origin"], Path::new(target))?;
377 } else {
378 git(&["init", "--bare", "--quiet", target], Path::new("."))?;
379 }
380 }
381 Ok(report)
382}
383
384/// The manifest's repository list as `name -> has a bundle`, with every
385/// name checked before it is ever joined to a path.
386///
387/// The check is here rather than at each use because both halves take
388/// this list from a directory somebody else produced: a name of `../..`
389/// would otherwise write outside the root being imported into, and read
390/// outside the export being verified.
391fn rows(manifest: &serde_json::Value) -> Result<BTreeMap<String, bool>, String> {
392 let mut out = BTreeMap::new();
393 for row in manifest
394 .get("repos")
395 .and_then(serde_json::Value::as_array)
396 .ok_or("manifest names no repository list")?
397 {
398 let name = row
399 .get("name")
400 .and_then(serde_json::Value::as_str)
401 .ok_or("a manifest row names no repository")?;
402 if !name.ends_with(".git")
403 || name.starts_with('/')
404 || name.split('/').any(|part| part.is_empty() || part == "..")
405 {
406 return Err(format!(
407 "manifest names `{name}`, which is not a repository path"
408 ));
409 }
410 out.insert(
411 name.to_string(),
412 !row.get("bundle").is_none_or(serde_json::Value::is_null),
413 );
414 }
415 Ok(out)
416}
417
418/// Verifies the chain and returns `(records, head-hex)`.
419fn chain_report(log: &Path) -> Result<(u64, Option<String>), String> {
420 let report =
421 choir_oplog::repair::verify(log).map_err(|e| format!("{}: {e:?}", log.display()))?;
422 if let Some(fault) = report.fault {
423 return Err(format!(
424 "{}: op log refused at record {}: {fault}",
425 log.display(),
426 fault.position()
427 ));
428 }
429 if report.torn_tail_bytes != 0 {
430 return Err(format!(
431 "{}: op log has an unterminated {}-byte tail; an export needs a complete record boundary",
432 log.display(),
433 report.torn_tail_bytes
434 ));
435 }
436 Ok((
437 report.intact_records,
438 report.head.as_ref().map(choir_hash::ContentHash::to_hex),
439 ))
440}
441
442/// Folds the log and groups its refs by repository, as git oids.
443fn log_refs(log: &Path) -> Result<BTreeMap<String, BTreeMap<String, String>>, String> {
444 let backend = choir_oplog::FileLog::open(log).map_err(|e| format!("open log: {e:?}"))?;
445 let view = choir_view::View::materialize(&backend).map_err(|e| format!("fold log: {e:?}"))?;
446 let refs = view_refs(&view);
447 match refs.unbacked.into_iter().next() {
448 Some(first) => Err(first),
449 None => Ok(refs.by_repo),
450 }
451}
452
453/// A view's refs, grouped by repository as git oids, and the ones no
454/// repository could hold.
455#[derive(Debug, Default)]
456pub struct ViewRefs {
457 /// `repo -> refname -> oid`, for every ref named `<repo>:<refname>`
458 /// whose value is a git oid.
459 pub by_repo: BTreeMap<String, BTreeMap<String, String>>,
460 /// One sentence per ref that names no repository or whose value is
461 /// not a git oid. The API can write either, and no bundle or bare
462 /// repository can back one, so a check that wants every ref backed
463 /// has to be told about them rather than find them absent.
464 pub unbacked: Vec<String>,
465}
466
467/// Groups `view`'s refs by the repository each names.
468///
469/// Shared by [`verify`], which checks them against an export's bundles,
470/// and by a seed, which checks them against the bare repositories it
471/// fetched. One function, so "the refs the view names" means one thing
472/// whichever copy is being checked.
473#[must_use]
474pub fn view_refs(view: &choir_view::View) -> ViewRefs {
475 let mut out = ViewRefs::default();
476 for (key, hash) in &view.refs {
477 let Some((repo, name)) = key.split_once(':') else {
478 out.unbacked
479 .push(format!("view ref key `{key}` names no repository"));
480 continue;
481 };
482 let Some(oid) = hash.git_oid() else {
483 out.unbacked
484 .push(format!("view ref `{key}` does not hold a git oid"));
485 continue;
486 };
487 out.by_repo
488 .entry(repo.to_string())
489 .or_default()
490 .insert(name.to_string(), oid);
491 }
492 out
493}
494
495/// One ref the view names that a copy does not hold where the view says.
496#[derive(Debug, Clone, PartialEq, Eq)]
497pub enum RefDisagreement {
498 /// The copy holds the ref, at another oid.
499 Moved {
500 /// The full refname.
501 name: String,
502 /// The oid the view names.
503 wanted: String,
504 /// The oid the copy holds.
505 found: String,
506 },
507 /// The copy does not hold the ref at all.
508 Missing {
509 /// The full refname.
510 name: String,
511 /// The oid the view names.
512 wanted: String,
513 },
514}
515
516/// How one repository's refs compare with the refs a view names for it.
517#[derive(Debug, Default, Clone, PartialEq, Eq)]
518pub struct RefCheck {
519 /// Refs the view names that the copy holds at the same oid.
520 pub matched: usize,
521 /// Refs the copy holds that the view does not name. Benign by itself;
522 /// see the module docs on why the check runs in one direction.
523 pub ahead: usize,
524 /// Refs the view names that the copy does not hold at that oid, in
525 /// refname order.
526 pub disagreements: Vec<RefDisagreement>,
527}
528
529/// Compares the refs a view names for one repository (`wanted`) with the
530/// refs a copy of it holds (`held`), both `refname -> oid`.
531///
532/// One direction, on purpose: every wanted ref must be held at its oid,
533/// and a held ref nobody wanted is counted in [`RefCheck::ahead`] rather
534/// than refused.
535#[must_use]
536pub fn check_refs(wanted: &BTreeMap<String, String>, held: &BTreeMap<String, String>) -> RefCheck {
537 let mut check = RefCheck::default();
538 for (name, oid) in wanted {
539 match held.get(name) {
540 Some(found) if found == oid => check.matched += 1,
541 Some(found) => check.disagreements.push(RefDisagreement::Moved {
542 name: name.clone(),
543 wanted: oid.clone(),
544 found: found.clone(),
545 }),
546 None => check.disagreements.push(RefDisagreement::Missing {
547 name: name.clone(),
548 wanted: oid.clone(),
549 }),
550 }
551 }
552 check.ahead = held
553 .keys()
554 .filter(|name| !wanted.contains_key(*name))
555 .count();
556 check
557}
558
559/// Bare repositories under `root`, as `owner/name.git` paths.
560///
561/// Public because the export is not the only caller that needs to know
562/// what is actually on disk. Startup needs it too: a repository can
563/// arrive without ever being named in `--create` — restored from a
564/// bundle, or created against a running node — and one that is served
565/// without being adopted is served with no `pre-receive` hook, which
566/// means every push into it bypasses the sequencer.
567///
568/// A directory counts as a repository when its name ends `.git` and it
569/// holds a `HEAD` file, so a half-written directory is not mistaken for
570/// one. `.choir` is skipped, and symlinks are never followed: a link out
571/// of the root would otherwise let an export copy, or a startup adopt,
572/// whatever it pointed at.
573///
574/// # Errors
575///
576/// Returns a description when a directory under `root` cannot be read.
577pub fn repos(root: &Path) -> Result<Vec<String>, String> {
578 let mut found = Vec::new();
579 let mut stack = vec![(root.to_path_buf(), String::new())];
580 while let Some((dir, prefix)) = stack.pop() {
581 for entry in std::fs::read_dir(&dir).map_err(|e| format!("read {dir:?}: {e}"))? {
582 let entry = entry.map_err(|e| format!("read {dir:?}: {e}"))?;
583 // `DirEntry::file_type` does not follow symlinks, so a link
584 // out of the root is skipped rather than followed. An export
585 // that chased one would copy whatever it pointed at.
586 if !entry
587 .file_type()
588 .map_err(|e| format!("stat: {e}"))?
589 .is_dir()
590 {
591 continue;
592 }
593 let name = entry.file_name().to_string_lossy().into_owned();
594 if name == ".choir" {
595 continue;
596 }
597 let rel = if prefix.is_empty() {
598 name.clone()
599 } else {
600 format!("{prefix}/{name}")
601 };
602 if name.ends_with(".git") && entry.path().join("HEAD").is_file() {
603 found.push(rel);
604 } else {
605 stack.push((entry.path(), rel));
606 }
607 }
608 }
609 found.sort();
610 Ok(found)
611}
612
613/// Every file under `dir`, recursively.
614fn walk(dir: &Path) -> Result<Vec<PathBuf>, String> {
615 let mut found = Vec::new();
616 let mut stack = vec![dir.to_path_buf()];
617 while let Some(dir) = stack.pop() {
618 for entry in std::fs::read_dir(&dir).map_err(|e| format!("read {dir:?}: {e}"))? {
619 let entry = entry.map_err(|e| format!("read {dir:?}: {e}"))?;
620 if entry
621 .file_type()
622 .map_err(|e| format!("stat: {e}"))?
623 .is_dir()
624 {
625 stack.push(entry.path());
626 } else {
627 found.push(entry.path());
628 }
629 }
630 }
631 Ok(found)
632}
633
634/// `refname -> oid` for a bundle, read from its header alone.
635fn bundle_heads(bundle: &Path) -> Result<BTreeMap<String, String>, String> {
636 let path = bundle.to_str().ok_or("bundle path is not utf-8")?;
637 let out = git(&["bundle", "list-heads", path], Path::new("."))?;
638 let mut heads = BTreeMap::new();
639 for line in out.lines() {
640 // `HEAD` is listed beside the refs and is a symbolic pointer, not
641 // a ref any op ever names.
642 if let Some((oid, name)) = line.split_once(' ') {
643 if name.starts_with("refs/") {
644 heads.insert(name.to_string(), oid.to_string());
645 }
646 }
647 }
648 Ok(heads)
649}
650
651/// Copies one file, reporting which one failed.
652fn copy(from: &Path, to: &Path) -> Result<(), String> {
653 std::fs::copy(from, to)
654 .map(|_| ())
655 .map_err(|e| format!("copy {} to {}: {e}", from.display(), to.display()))
656}
657
658/// Runs git in `dir`, returning stdout or a failure description.
659fn git(args: &[&str], dir: &Path) -> Result<String, String> {
660 let out = std::process::Command::new("git")
661 .args(args)
662 .current_dir(dir)
663 .env("GIT_TERMINAL_PROMPT", "0")
664 .output()
665 .map_err(|e| format!("spawn git: {e}"))?;
666 if out.status.success() {
667 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
668 } else {
669 Err(format!(
670 "git {} in {}: {}",
671 args.join(" "),
672 dir.display(),
673 String::from_utf8_lossy(&out.stderr).trim()
674 ))
675 }
676}