nornir 0.4.10

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
//! Pure-Rust git helpers (via `gix`). The single home for read-side
//! git inspection so the rest of the crate never shells out to the
//! `git` binary. No `std::process::Command`, no libgit2/C — matches
//! nornir's pure-Rust ethos (gix over libgit2, like `ureq` over curl).
//!
//! Write/network release operations (commit-on-release, push) live in
//! [`crate::release::publish`]. The write helpers here — [`init`] and
//! [`commit_all`] — exist only to build *new* repositories from scratch
//! (test fixtures and the synthetic-repo generator) without ever
//! shelling out; they use a fixed, deterministic identity so fixtures
//! are reproducible and need no ambient git config.

use std::path::{Path, PathBuf};

use anyhow::{anyhow, Context, Result};

/// Full 40-char hex SHA of the commit `HEAD` points at.
pub fn head_sha(root: &Path) -> Result<String> {
    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    let id = repo
        .head()
        .context("read HEAD")?
        .id()
        .ok_or_else(|| anyhow!("HEAD has no commit (unborn?) in {}", root.display()))?;
    Ok(id.to_string())
}

/// Short branch name for `HEAD`, or `"(detached)"` when detached.
pub fn head_branch(root: &Path) -> Result<String> {
    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    Ok(match repo.head_name().context("read HEAD name")? {
        Some(name) => name.shorten().to_string(),
        None => "(detached)".to_string(),
    })
}

/// `(sha, branch)` for `HEAD` in one open. Branch falls back to
/// `"(detached)"` when `HEAD` is not on a branch.
pub fn head_sha_and_branch(root: &Path) -> Result<(String, String)> {
    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    let sha = repo
        .head()
        .context("read HEAD")?
        .id()
        .ok_or_else(|| anyhow!("HEAD has no commit (unborn?) in {}", root.display()))?
        .to_string();
    let branch = match repo.head_name().context("read HEAD name")? {
        Some(name) => name.shorten().to_string(),
        None => "(detached)".to_string(),
    };
    Ok((sha, branch))
}

/// Commit SHA the tag `refs/tags/<tag>` resolves to, peeling annotated
/// tags down to their target commit. `Ok(None)` if the tag is absent.
pub fn tag_commit_sha(root: &Path, tag: &str) -> Result<Option<String>> {
    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    let full = format!("refs/tags/{tag}");
    let Some(reference) = repo
        .try_find_reference(full.as_str())
        .with_context(|| format!("look up {full}"))?
    else {
        return Ok(None);
    };
    let id = reference
        .into_fully_peeled_id()
        .with_context(|| format!("peel {full}"))?;
    Ok(Some(id.to_string()))
}

/// True iff `refs/tags/<tag>` exists and (after peeling) points at the
/// same commit as `HEAD`.
pub fn tag_points_at_head(root: &Path, tag: &str) -> Result<bool> {
    match tag_commit_sha(root, tag)? {
        Some(tag_sha) => Ok(tag_sha == head_sha(root)?),
        None => Ok(false),
    }
}

/// Fixed identity for fixture/generated commits — deterministic so test
/// repos hash reproducibly and require no ambient `git` config.
const FIXTURE_NAME: &str = "Nornir Fixture";
const FIXTURE_EMAIL: &str = "fixtures@nornir.invalid";
const FIXTURE_TIME: &str = "1700000000 +0000";

/// Initialise a fresh git repository at `root` (pure-Rust via `gix`).
/// The directory is created if missing. Used by test fixtures and the
/// synthetic-repo generator so setup code is also free of `git`
/// subprocesses.
pub fn init(root: &Path) -> Result<()> {
    std::fs::create_dir_all(root).with_context(|| format!("mkdir {}", root.display()))?;
    gix::init(root).with_context(|| format!("gix init {}", root.display()))?;
    Ok(())
}

/// Snapshot the entire working tree (every file except those under
/// `.git/`) into a new commit on `HEAD`, returning the commit SHA.
///
/// Works for the initial commit (unborn `HEAD`, no parent) and for
/// subsequent commits (parent = current `HEAD`). Because the tree is
/// rebuilt from the filesystem each call, additions, modifications and
/// deletions are all captured — a faithful `git add -A && git commit`
/// for synthetic repos that have no `.gitattributes` content filters.
pub fn commit_all(root: &Path, message: &str) -> Result<String> {
    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    let empty = gix::ObjectId::empty_tree(repo.object_hash());
    let mut editor = repo.edit_tree(empty).context("seed empty tree editor")?;

    add_dir_recursive(&repo, &mut editor, root, root)?;
    let tree = editor.write().context("write fixture tree")?.detach();

    let parents: Vec<gix::ObjectId> = repo.head_commit().ok().map(|c| c.id).into_iter().collect();
    let sig = gix::actor::SignatureRef {
        name: gix::bstr::BStr::new(FIXTURE_NAME),
        email: gix::bstr::BStr::new(FIXTURE_EMAIL),
        time: FIXTURE_TIME,
    };
    let id = repo
        .commit_as(sig, sig, "HEAD", message, tree, parents)
        .context("create fixture commit")?;

    // Write the index from the new tree so the freshly-built repo reads
    // clean (gix::init leaves no index; without this the whole worktree
    // would look untracked to a subsequent status check).
    let mut index = repo
        .index_from_tree(&tree)
        .context("rebuild index from fixture tree")?;
    index
        .write(gix::index::write::Options::default())
        .context("persist fixture index")?;

    Ok(id.to_string())
}

/// Create a lightweight tag `refs/tags/<name>` pointing at `target_sha`.
/// Pure-gix (no shell). Fails if the tag already exists.
pub fn tag_lightweight(root: &Path, name: &str, target_sha: &str) -> Result<()> {
    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
    let target = gix::ObjectId::from_hex(target_sha.as_bytes())
        .with_context(|| format!("parse target sha `{target_sha}`"))?;
    repo.tag_reference(name, target, gix::refs::transaction::PreviousValue::MustNotExist)
        .with_context(|| format!("create tag `{name}` -> {target_sha}"))?;
    Ok(())
}

// ── Network (server-monitored poll/fetch) ───────────────────────────────────
//
// Pure-Rust git, no `git`/`ssh` subprocess, no C:
// - HTTPS: gix + rustls (clone/fetch).
// - SSH:   `russh` runs `git-upload-pack` over an authenticated channel and gix
//          drives the wire protocol + pack indexing over it (see [`ssh_sync`]).
//          gix's *own* SSH transport execs `ssh`, which we avoid.

/// `true` for SSH-style remotes (`git@host:…` or `ssh://…`).
pub fn is_ssh_url(url: &str) -> bool {
    url.starts_with("ssh://") || (url.contains('@') && !url.contains("://"))
}

/// Clone `url` into `dest` (full worktree). Pure Rust over HTTPS.
fn clone_repo(url: &str, dest: &Path) -> Result<()> {
    std::fs::create_dir_all(dest).with_context(|| format!("create {}", dest.display()))?;
    let mut prepare = gix::prepare_clone(url, dest)
        .with_context(|| format!("prepare clone {url}{}", dest.display()))?
        // Fetch ALL tags — release tags drive the viz timeline's git-history
        // fallback; gix's default refspec maps only branches.
        .configure_remote(|remote| {
            Ok::<_, Box<dyn std::error::Error + Send + Sync>>(
                remote.with_fetch_tags(gix::remote::fetch::Tags::All),
            )
        });
    let (mut checkout, _) = prepare
        .fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
        .with_context(|| format!("clone-fetch {url}"))?;
    checkout
        .main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
        .with_context(|| format!("checkout worktree for {url}"))?;
    Ok(())
}

/// Fetch the default remote of an existing HTTPS clone at `dest`.
fn fetch_repo(dest: &Path) -> Result<()> {
    let repo = gix::open(dest).with_context(|| format!("gix::open {}", dest.display()))?;
    let remote = repo
        .find_default_remote(gix::remote::Direction::Fetch)
        .ok_or_else(|| anyhow!("{} has no fetch remote", dest.display()))?
        .context("resolve default remote")?
        // Pull tags too, so newly-released versions appear in the timeline.
        .with_fetch_tags(gix::remote::fetch::Tags::All);
    remote
        .connect(gix::remote::Direction::Fetch)
        .context("connect to remote")?
        .prepare_fetch(gix::progress::Discard, Default::default())
        .context("prepare fetch")?
        .receive(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
        .context("fetch")?;
    Ok(())
}

/// Resolve the deploy-key path the server uses for git-over-SSH. Mirrors the
/// CLI's `nornir key` resolution: honors `NORNIR_SSH_DIR`, else the `nornir`
/// system user's home (`/home/nornir/.ssh`), else a per-user dev location that
/// never touches the operator's own `~/.ssh`. Returns the private key path only
/// if it exists.
pub fn nornir_ssh_key_path() -> Option<PathBuf> {
    let dir = if let Some(d) = std::env::var_os("NORNIR_SSH_DIR") {
        PathBuf::from(d)
    } else {
        let sys = Path::new("/home/nornir/.ssh");
        if sys.exists() {
            sys.to_path_buf()
        } else if let Some(home) = std::env::var_os("HOME") {
            Path::new(&home).join(".nornir/ssh")
        } else {
            return None;
        }
    };
    let key = dir.join("id_ed25519");
    key.exists().then_some(key)
}

/// Clone-or-fetch `url` into `dest` over SSH with the deploy key at `key_path`,
/// returning the fetched `HEAD` SHA.
///
/// `russh` opens an authenticated `git-upload-pack` channel; gix's blocking git
/// transport (`ConnectMode::Process`) negotiates and receives the pack over it,
/// writing objects + `refs/remotes/origin/*` into the repo. We then point HEAD
/// at the fetched commit and materialize the worktree from its tree.
fn ssh_sync(url: &str, dest: &Path, key_path: &Path) -> Result<String> {
    use gix::protocol::transport::client::git::blocking_io::Connection as GitConnection;
    use gix::protocol::transport::client::git::ConnectMode;
    use gix::protocol::transport::Protocol;

    let loc = crate::ssh::parse_ssh_url(url)?;

    // 1. Cheap ref advertisement: HEAD's SHA + the branch it points at.
    let refs = crate::ssh::ls_remote_blocking(url, key_path)
        .with_context(|| format!("ssh ls-remote {url}"))?;
    let head_sha = refs
        .iter()
        .find(|(_, name)| name == "HEAD")
        .map(|(sha, _)| sha.clone())
        .ok_or_else(|| anyhow!("remote {url} advertised no HEAD"))?;
    // GitHub advertises HEAD's branch too; match by SHA to learn its name.
    let branch = refs
        .iter()
        .find(|(sha, name)| sha == &head_sha && name.starts_with("refs/heads/"))
        .map(|(_, name)| name.clone())
        .unwrap_or_else(|| "refs/heads/main".to_string());

    // Cheap change-detection (ls-remote's purpose): if the worktree is already at
    // this commit, there's nothing to fetch — the common steady-state poll.
    if dest.join(".git").exists()
        && crate::gitio::head_sha(dest).ok().as_deref() == Some(head_sha.as_str())
    {
        return Ok(head_sha);
    }
    eprintln!("nornir-ssh: {url} HEAD={head_sha} changed; fetching pack…");

    // 2. Repo: open if present, else init a fresh non-bare one.
    let mut repo = if dest.join(".git").exists() {
        gix::open(dest).with_context(|| format!("gix::open {}", dest.display()))?
    } else {
        std::fs::create_dir_all(dest).with_context(|| format!("create {}", dest.display()))?;
        gix::init(dest).with_context(|| format!("gix::init {}", dest.display()))?
    };
    // A freshly init'd repo has no user identity; gix needs a committer to write
    // reflogs for the fetched ref updates (and our HEAD update below). Inject the
    // generic in-memory fallback so those succeed without ambient git config.
    let _ = repo.committer_or_set_generic_fallback();

    // 3. Fetch the pack over the russh channel. gix's blocking fetch runs on
    //    THIS thread; the upload-pack runtime's workers handle the socket I/O.
    let mut up = crate::ssh::connect_upload_pack(&loc, key_path)
        .with_context(|| format!("ssh upload-pack {url}"))?;
    let transport = GitConnection::new(
        &mut up.reader,
        &mut up.writer,
        Protocol::V2,
        loc.path.clone(),
        Option::<(String, Option<u16>)>::None,
        ConnectMode::Process,
        false,
    );
    let remote = repo
        .remote_at(url)
        .context("build in-memory remote")?
        .with_refspecs(
            [
                "+refs/heads/*:refs/remotes/origin/*",
                // Tags too — release tags drive the viz git-history fallback.
                "+refs/tags/*:refs/tags/*",
            ],
            gix::remote::Direction::Fetch,
        )
        .context("set fetch refspecs")?;
    remote
        .to_connection_with_transport(transport)
        .prepare_fetch(gix::progress::Discard, Default::default())
        .context("prepare ssh fetch")?
        .receive(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
        .context("ssh fetch (pack transfer)")?;
    drop(up); // close the ssh channel before touching the worktree

    // 4. Point HEAD at the fetched commit and materialize the worktree.
    set_head(&repo, &branch, &head_sha)?;
    materialize_worktree(&repo, &head_sha, dest)
        .with_context(|| format!("checkout {head_sha} into {}", dest.display()))?;
    eprintln!("nornir-ssh: {url} synced {head_sha}{}", dest.display());
    Ok(head_sha)
}

/// Create/update `branch` → `sha` (a direct ref) and point `HEAD` at `branch`.
fn set_head(repo: &gix::Repository, branch: &str, sha: &str) -> Result<()> {
    use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
    use gix::refs::{FullName, Target};

    let oid = gix::ObjectId::from_hex(sha.as_bytes()).with_context(|| format!("parse sha {sha}"))?;
    let branch_name: FullName = branch
        .try_into()
        .map_err(|e| anyhow!("invalid branch ref `{branch}`: {e}"))?;
    let log = LogChange {
        mode: RefLog::AndReference,
        force_create_reflog: false,
        message: "nornir: ssh fetch".into(),
    };
    let edits = vec![
        RefEdit {
            change: Change::Update {
                log: log.clone(),
                expected: PreviousValue::Any,
                new: Target::Object(oid),
            },
            name: branch_name.clone(),
            deref: false,
        },
        RefEdit {
            change: Change::Update {
                log,
                expected: PreviousValue::Any,
                new: Target::Symbolic(branch_name),
            },
            name: "HEAD".try_into().expect("HEAD is a valid ref name"),
            deref: false,
        },
    ];
    repo.edit_references(edits).context("set HEAD + branch")?;
    Ok(())
}

/// Materialize the worktree at `dest` from the tree of commit `sha`, using only
/// public gix APIs (no `gix-worktree-state`): clear everything but `.git`, then
/// write each index blob. Handles regular/executable files and symlinks; skips
/// submodule gitlinks. Sufficient for the read-only scans the monitor runs.
fn materialize_worktree(repo: &gix::Repository, sha: &str, dest: &Path) -> Result<()> {
    use gix::index::entry::Mode;

    let oid = gix::ObjectId::from_hex(sha.as_bytes()).with_context(|| format!("parse sha {sha}"))?;
    let tree_id = repo
        .find_commit(oid)
        .with_context(|| format!("find commit {sha}"))?
        .tree_id()
        .context("commit tree")?
        .detach();
    let index = repo
        .index_from_tree(&tree_id)
        .with_context(|| format!("index from tree {tree_id}"))?;

    // Clear the worktree (keep `.git`) so deletions upstream are reflected.
    for entry in std::fs::read_dir(dest).with_context(|| format!("read {}", dest.display()))? {
        let path = entry?.path();
        if path.file_name().map(|n| n == ".git").unwrap_or(false) {
            continue;
        }
        if path.is_dir() {
            std::fs::remove_dir_all(&path).ok();
        } else {
            std::fs::remove_file(&path).ok();
        }
    }

    for entry in index.entries() {
        if entry.mode == Mode::COMMIT {
            continue; // submodule gitlink — no blob to write
        }
        let rel = gix::path::from_bstr(entry.path(&index));
        let full = dest.join(rel.as_ref());
        if let Some(parent) = full.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("mkdir {}", parent.display()))?;
        }
        let blob = repo
            .find_object(entry.id)
            .with_context(|| format!("blob {} for {}", entry.id, full.display()))?;

        if entry.mode == Mode::SYMLINK {
            #[cfg(unix)]
            {
                use std::os::unix::ffi::OsStrExt;
                let target = std::ffi::OsStr::from_bytes(&blob.data);
                std::fs::remove_file(&full).ok();
                std::os::unix::fs::symlink(target, &full)
                    .with_context(|| format!("symlink {}", full.display()))?;
            }
            #[cfg(not(unix))]
            std::fs::write(&full, &blob.data).with_context(|| format!("write {}", full.display()))?;
        } else {
            std::fs::write(&full, &blob.data).with_context(|| format!("write {}", full.display()))?;
            #[cfg(unix)]
            if entry.mode == Mode::FILE_EXECUTABLE {
                use std::os::unix::fs::PermissionsExt;
                std::fs::set_permissions(&full, std::fs::Permissions::from_mode(0o755)).ok();
            }
        }
    }
    Ok(())
}

/// Ensure `dest` is an up-to-date clone of `url`: clone if absent, else fetch.
/// Returns the resulting `HEAD` SHA. SSH remotes use the deploy key at
/// `ssh_key` (resolved via [`nornir_ssh_key_path`] when `None`).
pub fn clone_or_fetch(url: &str, dest: &Path, ssh_key: Option<&Path>) -> Result<String> {
    if is_ssh_url(url) {
        let resolved;
        let key = match ssh_key {
            Some(k) => k,
            None => {
                resolved = nornir_ssh_key_path().ok_or_else(|| {
                    anyhow!(
                        "SSH remote `{url}` needs a deploy key, but none was found \
                         (set NORNIR_SSH_DIR or install the service so the key lives \
                         at /home/nornir/.ssh/id_ed25519 — see `nornir key show`)"
                    )
                })?;
                resolved.as_path()
            }
        };
        return ssh_sync(url, dest, key);
    }
    if dest.join(".git").exists() {
        fetch_repo(dest)?;
    } else {
        clone_repo(url, dest)?;
    }
    head_sha(dest)
}

/// Recursively upsert every file under `dir` into `editor`, keyed by its
/// path relative to `repo_root`. Skips the `.git` directory.
fn add_dir_recursive(
    repo: &gix::Repository,
    editor: &mut gix::object::tree::Editor<'_>,
    repo_root: &Path,
    dir: &Path,
) -> Result<()> {
    use gix::object::tree::EntryKind;

    let mut entries: Vec<_> = std::fs::read_dir(dir)
        .with_context(|| format!("read_dir {}", dir.display()))?
        .collect::<std::io::Result<Vec<_>>>()
        .with_context(|| format!("iterate {}", dir.display()))?;
    // Deterministic order keeps generated history reproducible.
    entries.sort_by_key(|e| e.file_name());

    for entry in entries {
        let path = entry.path();
        let name = entry.file_name();
        if name == ".git" {
            continue;
        }
        let meta = std::fs::symlink_metadata(&path)
            .with_context(|| format!("stat {}", path.display()))?;
        let ft = meta.file_type();
        if ft.is_dir() {
            add_dir_recursive(repo, editor, repo_root, &path)?;
            continue;
        }
        let rela = path
            .strip_prefix(repo_root)
            .expect("path is under repo_root");
        let rela = gix::path::into_bstr(rela).into_owned();

        let (bytes, kind): (Vec<u8>, EntryKind) = if ft.is_symlink() {
            let target = std::fs::read_link(&path)
                .with_context(|| format!("readlink {}", path.display()))?;
            (gix::path::into_bstr(target).into_owned().into(), EntryKind::Link)
        } else {
            let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
            #[cfg(unix)]
            let kind = {
                use std::os::unix::fs::PermissionsExt;
                if meta.permissions().mode() & 0o111 != 0 {
                    EntryKind::BlobExecutable
                } else {
                    EntryKind::Blob
                }
            };
            #[cfg(not(unix))]
            let kind = EntryKind::Blob;
            (bytes, kind)
        };

        let blob = repo.write_blob(&bytes).context("write blob")?;
        editor
            .upsert(rela.as_ref() as &gix::bstr::BStr, kind, blob.detach())
            .with_context(|| format!("tree upsert {rela}"))?;
    }
    Ok(())
}