Skip to main content

choir_cli/
follower.rs

1//! `choir repo follower` — the remotes a node pushes its landings to
2//! (D21), managed on the node host.
3//!
4//! A follower is a remote on a bare repository under the state
5//! directory. `add` writes one with `git remote add` and drops the
6//! marker that makes `choir node serve` start the daemon with
7//! `--followers`; `list` reads them back; `push` pushes now, which is
8//! what the daemon does after every landing once restarted. Nothing
9//! here fetches, and nothing forces: the node is canonical, a follower
10//! copies it, and one that diverged is a push that refuses.
11//!
12//! # Examples
13//!
14//! ```
15//! use choir_cli::follower::repository_name;
16//!
17//! // `.git` is the bare repository's own spelling, added when missing.
18//! assert_eq!(repository_name("me/thing"), "me/thing.git");
19//! assert_eq!(repository_name("me/thing.git"), "me/thing.git");
20//! ```
21
22/// A repository as `repos.list` and the root spell it.
23#[must_use]
24pub fn repository_name(name: &str) -> String {
25    if name.ends_with(".git") {
26        name.to_string()
27    } else {
28        format!("{name}.git")
29    }
30}
31
32/// The repositories a node holds, in `repos.list` order.
33#[must_use]
34pub fn repositories(layout: &crate::serve::Layout) -> Vec<String> {
35    std::fs::read_to_string(layout.state.join("repos.list"))
36        .unwrap_or_default()
37        .lines()
38        .map(str::trim)
39        .filter(|l| !l.is_empty() && !l.starts_with('#'))
40        .map(str::to_string)
41        .collect()
42}
43
44/// Adds one remote to one repository, and the marker if it is not there.
45///
46/// Returns whether the marker was written now: the daemon reads it at
47/// start, so a marker written now means a restart before landings
48/// follow.
49///
50/// # Errors
51///
52/// Returns a description when the repository is not under the root, the
53/// name is already a remote, or git refuses.
54pub fn add(
55    layout: &crate::serve::Layout,
56    repo: &str,
57    name: &str,
58    url: &str,
59) -> Result<bool, String> {
60    let repo = repository_name(repo);
61    let bare = layout.repos.join(&repo);
62    if !bare.join("HEAD").is_file() {
63        return Err(format!(
64            "no repository {repo} under {}\n\n  what is here: choir repo follower list",
65            layout.repos.display()
66        ));
67    }
68    if name.is_empty() || name.starts_with('-') || name.contains(['/', ' ']) {
69        return Err(format!("{name:?} is not a remote name"));
70    }
71    if choir_node::followers::remotes(&bare)
72        .iter()
73        .any(|(n, _)| n == name)
74    {
75        return Err(format!(
76            "{repo} already has a remote named {name}\n\n  \
77             see it: git --git-dir {} remote -v",
78            bare.display()
79        ));
80    }
81    let out = std::process::Command::new("git")
82        .arg("--git-dir")
83        .arg(&bare)
84        .args(["remote", "add", name, url])
85        .output()
86        .map_err(|e| format!("could not run git: {e}"))?;
87    if !out.status.success() {
88        return Err(format!(
89            "git remote add refused: {}",
90            String::from_utf8_lossy(&out.stderr).trim()
91        ));
92    }
93    let marker = &layout.followers_marker;
94    if marker.exists() {
95        return Ok(false);
96    }
97    choir_fs::write_atomic(
98        marker,
99        "# Landings are pushed to every remote of their repository (D21).\n\
100         # Written by `choir repo follower add`; `choir node serve` passes\n\
101         # --followers while this file exists. Delete it to stop.\n",
102    )
103    .map_err(|e| format!("write {}: {e}", marker.display()))?;
104    Ok(true)
105}
106
107/// Every repository with its remotes, `(repo, remotes)`.
108#[must_use]
109pub fn list(layout: &crate::serve::Layout) -> Vec<(String, Vec<(String, String)>)> {
110    repositories(layout)
111        .into_iter()
112        .map(|repo| {
113            let remotes = choir_node::followers::remotes(&layout.repos.join(&repo));
114            (repo, remotes)
115        })
116        .collect()
117}
118
119/// Pushes one repository, or every listed one, to each of its remotes.
120#[must_use]
121pub fn push(
122    layout: &crate::serve::Layout,
123    only: Option<&str>,
124) -> Vec<choir_node::followers::Outcome> {
125    let repos = match only {
126        Some(repo) => vec![repository_name(repo)],
127        None => repositories(layout),
128    };
129    repos
130        .iter()
131        .flat_map(|repo| choir_node::followers::push_all(&layout.repos, repo))
132        .collect()
133}
134
135/// Whether the marker that makes the daemon push after landings exists.
136#[must_use]
137pub fn following(layout: &crate::serve::Layout) -> bool {
138    layout.followers_marker.exists()
139}
140
141/// The bare repository's path, for the message that names it.
142#[must_use]
143pub fn bare(layout: &crate::serve::Layout, repo: &str) -> std::path::PathBuf {
144    layout.repos.join(repository_name(repo))
145}