Skip to main content

lore/sync/
mod.rs

1//! Keeping one library across machines, through a git repository the user
2//! owns.
3//!
4//! lore keeps its own clone of the repository in its data directory and never
5//! touches git state anywhere else. The library file stays where it always
6//! was; a sync reads it, merges it by entry with what the repository holds,
7//! writes the result back, and commits and pushes it from the clone.
8//!
9//! Git is only the transport. It never merges anything itself, because it
10//! merges by line and two machines that each saved a command have both
11//! appended to the same list, which git reports as a conflict although nothing
12//! clashes. See `merge`.
13//!
14//! lore never sees a password or a token. It runs the user's own `git`, which
15//! already knows their SSH key or stored login.
16
17pub mod merge;
18
19use std::env;
20use std::fs;
21use std::io::{self, IsTerminal, Write};
22use std::path::{Path, PathBuf};
23use std::process::{Command, Stdio};
24use std::time::{Duration, SystemTime};
25
26use anyhow::{Context, Result, bail};
27
28use crate::store;
29
30/// Name of the library inside the repository.
31const LIBRARY: &str = "commands.yaml";
32
33/// Branch used when the repository has none yet.
34const DEFAULT_BRANCH: &str = "main";
35
36/// Git setting, stored in the clone's own config, naming the branch to sync.
37const BRANCH_KEY: &str = "lore.branch";
38
39/// Git setting holding the commit this machine last agreed with the
40/// repository on. The merge base, recorded rather than worked out: a fresh
41/// clone's checkout matches the repository without this machine ever having
42/// seen it, and treating it as agreed would make everything already in the
43/// library look deleted here.
44const SYNCED_KEY: &str = "lore.synced";
45
46/// Repository `lore sync init` creates when it can do so itself.
47const REPOSITORY_NAME: &str = "lore-library";
48
49/// Who a sync commit is by, unless the user gives the clone an identity.
50///
51/// Deliberately not the user's own: see `has_identity`. The address is a
52/// reserved name that can never belong to anyone, so no GitHub account is
53/// ever credited with it.
54const COMMIT_NAME: &str = "lore";
55const COMMIT_EMAIL: &str = "lore@invalid";
56
57/// Held while a sync runs, so two can never interleave their writes.
58const LOCK: &str = ".lore-sync.lock";
59
60/// Older than this, a lock was left by a sync that died.
61const STALE_LOCK: Duration = Duration::from_secs(120);
62
63/// How long a background sync waits for the one ahead of it.
64const BACKGROUND_WAIT: Duration = Duration::from_secs(30);
65
66/// Touched after every successful sync.
67const LAST_SYNC: &str = ".lore-last-sync";
68
69/// What the last background sync failed with, until one succeeds.
70const LAST_ERROR: &str = ".lore-sync-error";
71
72/// How long the picker lets pass before it asks for other machines' changes.
73///
74/// Fetching on every keystroke that opens it would make the picker wait on the
75/// network. This fetches in the background at most this often instead.
76const REFRESH: Duration = Duration::from_secs(15 * 60);
77
78/// How a sync was started, which decides how it may fail.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum Mode {
81    /// Someone ran `lore sync` and is watching.
82    Interactive,
83    /// Started by lore itself after a change. Must never ask anything, since
84    /// nobody is there to answer, and reports failure by leaving a note.
85    Background,
86}
87
88/// What a sync did.
89#[derive(Debug, Default)]
90pub struct Report {
91    pub received: usize,
92    pub sent: usize,
93    pub kept_both: Vec<(String, String)>,
94}
95
96impl Report {
97    pub fn summary(&self) -> String {
98        let mut lines = Vec::new();
99        lines.push(match (self.received, self.sent) {
100            (0, 0) => "Already up to date".to_string(),
101            (received, sent) => format!(
102                "Synced: {} from other machines, {} from this one",
103                count(received, "change"),
104                count(sent, "change")
105            ),
106        });
107        for (id, copy) in &self.kept_both {
108            lines.push(format!(
109                "{id} was changed on two machines. The one synced first kept the id, \
110                 this machine's version is now {copy}"
111            ));
112        }
113        lines.join("\n")
114    }
115}
116
117fn count(n: usize, noun: &str) -> String {
118    if n == 1 {
119        format!("1 {noun}")
120    } else {
121        format!("{n} {noun}s")
122    }
123}
124
125/// Whether this machine has sync set up.
126pub fn is_configured() -> bool {
127    store::sync_dir().is_ok_and(|dir| dir.join(".git").is_dir())
128}
129
130/// Connects this machine to a repository, creating one first when no address
131/// is given and the GitHub CLI can do it.
132pub fn init(url: Option<String>) -> Result<Report> {
133    require_git()?;
134    let dir = store::sync_dir()?;
135
136    if dir.join(".git").is_dir() {
137        let current = remote_url(&dir)?;
138        match &url {
139            Some(url) if url != &current => bail!(
140                "this machine already syncs with {current}. \
141                 Run `lore sync disconnect` first to switch"
142            ),
143            _ => {
144                println!("Already syncing with {current}");
145                return run(Mode::Interactive);
146            }
147        }
148    }
149
150    let addresses = match url {
151        Some(url) => vec![url],
152        None => addresses_of(&create_repository()?)?,
153    };
154
155    if let Some(parent) = dir.parent() {
156        fs::create_dir_all(parent)
157            .with_context(|| format!("failed to create {}", parent.display()))?;
158    }
159
160    let url = connect(&dir, &addresses)?;
161
162    let branch = git(
163        &dir,
164        Mode::Interactive,
165        &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
166    )
167    .ok()
168    .and_then(|reference| reference.strip_prefix("origin/").map(str::to_string))
169    .unwrap_or_else(|| DEFAULT_BRANCH.to_string());
170    git(&dir, Mode::Interactive, &["config", BRANCH_KEY, &branch])?;
171
172    warn_if_public(&url);
173    run(Mode::Interactive)
174}
175
176/// Merges this machine's library with the repository's and pushes the result.
177pub fn run(mode: Mode) -> Result<Report> {
178    let dir = store::sync_dir()?;
179    if !dir.join(".git").is_dir() {
180        bail!("sync is not set up on this machine. Run `lore sync init` first");
181    }
182    require_git()?;
183
184    // Syncs queue rather than collide. A background sync waits its turn
185    // because the change that started it is not in the repository yet and
186    // nothing else will send it until the next save; if the wait is long
187    // enough to look like trouble it gives up quietly, and the picker's
188    // refresh will carry the change later. Someone who typed `lore sync`
189    // waits longer and is told when it is hopeless.
190    let _lock = match mode {
191        Mode::Background => match Lock::wait(&dir, BACKGROUND_WAIT)? {
192            Some(lock) => lock,
193            None => return Ok(Report::default()),
194        },
195        Mode::Interactive => match Lock::wait(&dir, STALE_LOCK)? {
196            Some(lock) => lock,
197            None => bail!("another sync has been running for two minutes, try again later"),
198        },
199    };
200
201    let result = sync_once(&dir, mode).or_else(|error| {
202        // The one failure worth trying again: another machine pushed between
203        // this one fetching and pushing. The second attempt merges that too.
204        if is_rejected_push(&error) {
205            sync_once(&dir, mode)
206        } else {
207            Err(error)
208        }
209    });
210
211    match &result {
212        Ok(_) => {
213            let _ = fs::write(dir.join(LAST_SYNC), "");
214            let _ = fs::remove_file(dir.join(LAST_ERROR));
215        }
216        Err(error) if mode == Mode::Background => {
217            let _ = fs::write(dir.join(LAST_ERROR), format!("{error:#}"));
218        }
219        Err(_) => {}
220    }
221
222    result
223}
224
225fn sync_once(dir: &Path, mode: Mode) -> Result<Report> {
226    let branch = git(dir, mode, &["config", "--get", BRANCH_KEY])
227        .unwrap_or_else(|_| DEFAULT_BRANCH.to_string());
228    let remote = format!("refs/remotes/origin/{branch}");
229
230    git(dir, mode, &["fetch", "--quiet", "origin"])
231        .context("could not fetch from the repository")?;
232
233    let has_remote = resolves(dir, mode, &remote);
234
235    let theirs = if has_remote {
236        file_at(dir, mode, &remote)?
237    } else {
238        None
239    };
240
241    // The last state this machine and the repository agreed on. Only ever
242    // recorded after a sync succeeds, so a push that failed is never taken for
243    // agreement either.
244    let base = match git(dir, mode, &["config", "--get", SYNCED_KEY]) {
245        Ok(commit) => file_at(dir, mode, &commit)?,
246        Err(_) => None,
247    };
248
249    let library = store::user_library()?;
250    let ours = match fs::read_to_string(&library) {
251        Ok(text) => text,
252        Err(error) if error.kind() == io::ErrorKind::NotFound => String::new(),
253        Err(error) => {
254            return Err(error).with_context(|| format!("failed to read {}", library.display()));
255        }
256    };
257
258    let merged = merge::merge(base.as_deref(), &ours, theirs.as_deref())?;
259
260    // Nothing here and nothing there: an empty file is not worth a commit.
261    if theirs.is_none() && merged.text.trim().is_empty() {
262        return Ok(Report::default());
263    }
264
265    if merged.text != ours {
266        if let Some(parent) = library.parent() {
267            fs::create_dir_all(parent)
268                .with_context(|| format!("failed to create {}", parent.display()))?;
269        }
270        fs::write(&library, &merged.text)
271            .with_context(|| format!("failed to write {}", library.display()))?;
272    }
273
274    // The commit is built on top of whatever the repository holds, so history
275    // stays a straight line and the push is always a fast forward.
276    if has_remote {
277        git(dir, mode, &["reset", "--quiet", "--soft", &remote])?;
278    }
279
280    fs::write(dir.join(LIBRARY), &merged.text)
281        .with_context(|| format!("failed to write {}", dir.join(LIBRARY).display()))?;
282    git(dir, mode, &["add", LIBRARY])?;
283
284    let staged = !git_in(Some(dir), mode)
285        .args(["diff", "--cached", "--quiet"])
286        .status()
287        .context("failed to run git")?
288        .success();
289    if staged {
290        let message = format!("Sync from {}", machine_name());
291        let mut commit = git_in(Some(dir), mode);
292        if !has_identity(dir, mode) {
293            commit.args([
294                "-c",
295                &format!("user.name={COMMIT_NAME}"),
296                "-c",
297                &format!("user.email={COMMIT_EMAIL}"),
298            ]);
299        }
300        commit.args(["commit", "--quiet", "--no-verify", "-m", &message]);
301        checked(commit, "commit")?;
302    }
303
304    let ahead = match (has_remote, resolves(dir, mode, "HEAD")) {
305        (_, false) => false,
306        (false, true) => true,
307        (true, true) => {
308            git(dir, mode, &["rev-parse", "HEAD"])? != git(dir, mode, &["rev-parse", &remote])?
309        }
310    };
311    if ahead {
312        let target = format!("HEAD:refs/heads/{branch}");
313        git(dir, mode, &["push", "--quiet", "origin", &target])
314            .context("could not push to the repository")?;
315    }
316
317    if let Ok(agreed) = git(dir, mode, &["rev-parse", "HEAD"]) {
318        git(dir, mode, &["config", SYNCED_KEY, &agreed])?;
319    }
320
321    Ok(Report {
322        received: merged.received,
323        sent: merged.sent,
324        kept_both: merged.kept_both,
325    })
326}
327
328/// Prints where this machine syncs, when it last did, and why it last failed.
329pub fn status() -> Result<()> {
330    let dir = store::sync_dir()?;
331    if !dir.join(".git").is_dir() {
332        println!("Sync is not set up on this machine. Run `lore sync init` to start");
333        return Ok(());
334    }
335
336    println!("Syncing with {}", remote_url(&dir)?);
337    match fs::metadata(dir.join(LAST_SYNC)).and_then(|meta| meta.modified()) {
338        Ok(at) => println!("Last synced {}", ago(at)),
339        Err(_) => println!("Not synced yet"),
340    }
341    if let Some(error) = last_error() {
342        println!("The last automatic sync failed: {error}");
343        println!("Run `lore sync` to try again and see the whole message");
344    }
345    Ok(())
346}
347
348/// Stops syncing on this machine. The library stays, and so does the
349/// repository.
350pub fn disconnect() -> Result<()> {
351    let dir = store::sync_dir()?;
352    if !dir.join(".git").is_dir() {
353        println!("Sync is not set up on this machine");
354        return Ok(());
355    }
356
357    let url = remote_url(&dir).unwrap_or_default();
358    fs::remove_dir_all(&dir).with_context(|| format!("failed to remove {}", dir.display()))?;
359    println!("Stopped syncing with {url}. Your library stays where it is");
360    Ok(())
361}
362
363/// Why the last background sync failed, if it did.
364pub fn last_error() -> Option<String> {
365    let dir = store::sync_dir().ok()?;
366    let text = fs::read_to_string(dir.join(LAST_ERROR)).ok()?;
367    let first = text.lines().next()?.trim();
368    (!first.is_empty()).then(|| first.to_string())
369}
370
371/// Starts a sync in the background, when sync is set up, and returns at once.
372///
373/// Used after every change and when the picker opens, so nobody waits on the
374/// network. Failures are left for `lore sync status` and the picker to report.
375pub fn spawn() {
376    if !is_configured() || automatic_sync_is_off() {
377        return;
378    }
379    let Ok(exe) = env::current_exe() else {
380        return;
381    };
382
383    let mut command = Command::new(exe);
384    command
385        .args(["sync", "--background"])
386        .stdin(Stdio::null())
387        .stdout(Stdio::null())
388        .stderr(Stdio::null());
389
390    #[cfg(unix)]
391    {
392        use std::os::unix::process::CommandExt;
393        // Its own process group, so closing the terminal does not take a sync
394        // down halfway, and ctrl+c at the prompt is not delivered to it.
395        command.process_group(0);
396    }
397    #[cfg(windows)]
398    {
399        use std::os::windows::process::CommandExt;
400        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
401        const DETACHED_PROCESS: u32 = 0x0000_0008;
402        command.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS);
403    }
404
405    let _ = command.spawn();
406}
407
408/// Starts a background sync if the last one was long enough ago that other
409/// machines may have changed something.
410pub fn refresh_if_stale() {
411    let Ok(dir) = store::sync_dir() else {
412        return;
413    };
414    let fresh = fs::metadata(dir.join(LAST_SYNC))
415        .and_then(|meta| meta.modified())
416        .is_ok_and(|at| at.elapsed().is_ok_and(|age| age < REFRESH));
417    if !fresh {
418        spawn();
419    }
420}
421
422/// Set to anything to sync only when `lore sync` is run by hand.
423const NO_AUTO_SYNC: &str = "LORE_NO_AUTO_SYNC";
424
425fn automatic_sync_is_off() -> bool {
426    env::var_os(NO_AUTO_SYNC).is_some_and(|value| !value.is_empty())
427}
428
429/// Clones the first of `addresses` that git can log in to.
430///
431/// A repository has an ssh address and an https one, and which of them works
432/// depends on what the user has already set up for git rather than on what
433/// they told the GitHub CLI they prefer. Each is tried with prompting off, so
434/// one that would sit waiting for a username fails and lets the other be
435/// tried. If neither works and the GitHub CLI is there, it is asked to set up
436/// git's credentials, which is the missing piece when only https is on offer.
437fn connect(dir: &Path, addresses: &[String]) -> Result<String> {
438    let mut failures = Vec::new();
439
440    for (attempt, url) in addresses.iter().enumerate() {
441        println!("Connecting to {url}");
442        match clone(dir, url) {
443            Ok(()) => return Ok(url.clone()),
444            Err(error) => {
445                println!("  that address did not work");
446                failures.push(format!("{url}: {error}"));
447            }
448        }
449
450        // Worth one attempt at teaching git the login the GitHub CLI holds,
451        // then trying the addresses again.
452        if attempt + 1 == addresses.len() && setup_git_credentials() {
453            for url in addresses {
454                println!("Connecting to {url}");
455                if clone(dir, url).is_ok() {
456                    return Ok(url.clone());
457                }
458            }
459        }
460    }
461
462    bail!(
463        "could not reach the repository.\n  {}\n\n\
464         If you use ssh with GitHub, check that `ssh -T git@github.com` greets you. \
465         For https, `gh auth login` or a credential helper has to be set up first.",
466        failures.join("\n  ")
467    )
468}
469
470fn clone(dir: &Path, url: &str) -> Result<()> {
471    if dir.exists() {
472        fs::remove_dir_all(dir).with_context(|| format!("failed to clear {}", dir.display()))?;
473    }
474
475    // Prompting stays off even here: a clone that stops to ask for a username
476    // cannot be given up on in favour of an address that needs no password.
477    let output = git_in(None, Mode::Background)
478        .arg("clone")
479        .arg("--quiet")
480        .arg(url)
481        .arg(dir)
482        .output()
483        .context("failed to run git")?;
484
485    if !output.status.success() {
486        let _ = fs::remove_dir_all(dir);
487        let stderr = String::from_utf8_lossy(&output.stderr);
488        let reason = stderr
489            .lines()
490            .find(|line| !line.trim().is_empty())
491            .unwrap_or("git clone failed");
492        bail!("{}", reason.trim().trim_start_matches("fatal: "));
493    }
494    Ok(())
495}
496
497/// Asks the GitHub CLI to give git the login it already holds, reporting
498/// whether it could.
499fn setup_git_credentials() -> bool {
500    let done = Command::new("gh")
501        .args(["auth", "setup-git"])
502        .stdout(Stdio::null())
503        .stderr(Stdio::null())
504        .status()
505        .is_ok_and(|status| status.success());
506    if done {
507        println!("Set up git to use your GitHub CLI login");
508    }
509    done
510}
511
512/// Both addresses of a GitHub repository, the one the user prefers first.
513fn addresses_of(name: &str) -> Result<Vec<String>> {
514    let protocol = gh_output(&["config", "get", "git_protocol"]).unwrap_or_default();
515
516    let https = gh_output(&["repo", "view", name, "--json", "url", "--jq", ".url"]);
517    let ssh = gh_output(&["repo", "view", name, "--json", "sshUrl", "--jq", ".sshUrl"]);
518
519    let mut addresses: Vec<String> = if protocol == "ssh" {
520        vec![ssh, https]
521    } else {
522        vec![https, ssh]
523    }
524    .into_iter()
525    .flatten()
526    .collect();
527    addresses.dedup();
528
529    if addresses.is_empty() {
530        bail!("could not find the address of {name}");
531    }
532    Ok(addresses)
533}
534
535fn gh_output(arguments: &[&str]) -> Option<String> {
536    let output = Command::new("gh").args(arguments).output().ok()?;
537    let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
538    (output.status.success() && !value.is_empty()).then_some(value)
539}
540
541/// Makes a private repository with the GitHub CLI, or finds the one an earlier
542/// machine made, and returns its address.
543fn create_repository() -> Result<String> {
544    let instructions = format!(
545        "Create an empty private repository, for example {REPOSITORY_NAME} on GitHub, \
546         then run:\n\n    lore sync init <its address>\n\n\
547         With the GitHub CLI installed and logged in, `lore sync init` makes it for you"
548    );
549
550    let signed_in = Command::new("gh")
551        .args(["auth", "status"])
552        .stdout(Stdio::null())
553        .stderr(Stdio::null())
554        .status()
555        .is_ok_and(|status| status.success());
556    if !signed_in {
557        bail!("{instructions}");
558    }
559
560    let exists = Command::new("gh")
561        .args(["repo", "view", REPOSITORY_NAME, "--json", "name"])
562        .stdout(Stdio::null())
563        .stderr(Stdio::null())
564        .status()
565        .is_ok_and(|status| status.success());
566
567    if exists {
568        println!("Using your existing private repository {REPOSITORY_NAME}");
569    } else {
570        if !confirm(&format!(
571            "Create a private GitHub repository named {REPOSITORY_NAME} for your library?"
572        ))? {
573            bail!("{instructions}");
574        }
575        let created = Command::new("gh")
576            .args([
577                "repo",
578                "create",
579                REPOSITORY_NAME,
580                "--private",
581                "--description",
582                "My lore command library",
583            ])
584            .stdout(Stdio::null())
585            .output()
586            .context("failed to run gh")?;
587        if !created.status.success() {
588            bail!(
589                "could not create the repository: {}",
590                String::from_utf8_lossy(&created.stderr).trim()
591            );
592        }
593        println!("Created the private repository {REPOSITORY_NAME}");
594    }
595
596    Ok(REPOSITORY_NAME.to_string())
597}
598
599/// Saved commands often carry host names, user names and internal addresses,
600/// so a public repository is worth a warning. Only GitHub can be asked.
601fn warn_if_public(url: &str) {
602    let public = Command::new("gh")
603        .args([
604            "repo",
605            "view",
606            url,
607            "--json",
608            "isPrivate",
609            "--jq",
610            ".isPrivate",
611        ])
612        .stderr(Stdio::null())
613        .output()
614        .is_ok_and(|out| {
615            out.status.success() && String::from_utf8_lossy(&out.stdout).trim() == "false"
616        });
617    if public {
618        println!(
619            "Warning: this repository is public. Saved commands often contain server \
620             names and addresses, so consider making it private"
621        );
622    }
623}
624
625fn confirm(question: &str) -> Result<bool> {
626    if !io::stdin().is_terminal() {
627        return Ok(false);
628    }
629    print!("{question} [y/N] ");
630    io::stdout().flush()?;
631    let mut answer = String::new();
632    io::stdin().read_line(&mut answer)?;
633    Ok(matches!(answer.trim().to_lowercase().as_str(), "y" | "yes"))
634}
635
636fn require_git() -> Result<()> {
637    let found = Command::new("git")
638        .arg("--version")
639        .stdout(Stdio::null())
640        .stderr(Stdio::null())
641        .status()
642        .is_ok_and(|status| status.success());
643    if !found {
644        bail!("sync needs git, and git was not found on PATH");
645    }
646    Ok(())
647}
648
649/// A git command with the settings every sync needs, run in `dir`.
650///
651/// Line endings are left alone so a Windows checkout cannot rewrite the file
652/// under the merge. Signing and hooks are skipped because a sync that stops
653/// to ask for a passphrase or runs someone's pre-commit checks has stopped
654/// being a sync. In the background nothing may prompt: git gives up rather
655/// than asking for a password, and ssh rather than asking about a host key.
656fn git_in(dir: Option<&Path>, mode: Mode) -> Command {
657    let mut command = Command::new("git");
658    if let Some(dir) = dir {
659        command.arg("-C").arg(dir);
660    }
661    command.args(["-c", "core.autocrlf=false", "-c", "commit.gpgsign=false"]);
662    command.env("GIT_TERMINAL_PROMPT", "0");
663
664    if mode == Mode::Background {
665        command.stdin(Stdio::null());
666        let custom_ssh = env::var_os("GIT_SSH_COMMAND").is_some()
667            || dir.is_some_and(|dir| {
668                Command::new("git")
669                    .arg("-C")
670                    .arg(dir)
671                    .args(["config", "--get", "core.sshCommand"])
672                    .output()
673                    .is_ok_and(|out| out.status.success())
674            });
675        if !custom_ssh {
676            command.env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes");
677        }
678    }
679    command
680}
681
682/// Runs git in `dir` and returns its trimmed output.
683fn git(dir: &Path, mode: Mode, args: &[&str]) -> Result<String> {
684    let mut command = git_in(Some(dir), mode);
685    command.args(args);
686    checked(command, args.first().copied().unwrap_or("git"))
687}
688
689fn checked(mut command: Command, what: &str) -> Result<String> {
690    let output = command.output().context("failed to run git")?;
691    if !output.status.success() {
692        let stderr = String::from_utf8_lossy(&output.stderr);
693        let message = stderr.trim();
694        bail!(GitError {
695            what: what.to_string(),
696            message: if message.is_empty() {
697                format!("git {what} failed")
698            } else {
699                message.to_string()
700            },
701        });
702    }
703    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
704}
705
706/// A git command that failed, carrying what it said.
707#[derive(Debug)]
708struct GitError {
709    what: String,
710    message: String,
711}
712
713impl std::fmt::Display for GitError {
714    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
715        write!(f, "{}", self.message)
716    }
717}
718
719impl std::error::Error for GitError {}
720
721fn is_rejected_push(error: &anyhow::Error) -> bool {
722    error.chain().any(|cause| {
723        cause.downcast_ref::<GitError>().is_some_and(|git| {
724            git.what == "push"
725                && (git.message.contains("rejected")
726                    || git.message.contains("fetch first")
727                    || git.message.contains("non-fast-forward"))
728        })
729    })
730}
731
732fn resolves(dir: &Path, mode: Mode, reference: &str) -> bool {
733    git(dir, mode, &["rev-parse", "--verify", "--quiet", reference]).is_ok()
734}
735
736/// The library as it was at `revision`, or `None` if it did not exist there.
737fn file_at(dir: &Path, mode: Mode, revision: &str) -> Result<Option<String>> {
738    let path = format!("{revision}:{LIBRARY}");
739    if !resolves(dir, mode, &path) {
740        return Ok(None);
741    }
742    let output = git_in(Some(dir), mode)
743        .args(["show", &path])
744        .output()
745        .context("failed to run git")?;
746    if !output.status.success() {
747        bail!("could not read {LIBRARY} at {revision}");
748    }
749    Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()))
750}
751
752fn remote_url(dir: &Path) -> Result<String> {
753    git(dir, Mode::Interactive, &["remote", "get-url", "origin"])
754}
755
756/// Whether the user has given this clone an identity of its own.
757///
758/// Only the clone's own config counts, never the identity git uses everywhere
759/// else. A sync commit is bookkeeping, and one per saved command, so putting
760/// the user's name and email on it would file every save as a contribution on
761/// their GitHub profile and fill the graph with squares that stand for
762/// nothing. Someone who wants their own name on them can say so, by setting
763/// `user.name` and `user.email` with `git config` inside the sync directory.
764fn has_identity(dir: &Path, mode: Mode) -> bool {
765    let local = |key: &str| {
766        git(dir, mode, &["config", "--local", "--get", key]).is_ok_and(|value| !value.is_empty())
767    };
768    local("user.email") && local("user.name")
769}
770
771/// A name for this machine in commit messages, so the repository's history
772/// says where each change came from.
773fn machine_name() -> String {
774    env::var("COMPUTERNAME")
775        .ok()
776        .or_else(|| {
777            Command::new("hostname")
778                .output()
779                .ok()
780                .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
781        })
782        .filter(|name| !name.is_empty())
783        .unwrap_or_else(|| "a machine".to_string())
784}
785
786fn ago(at: SystemTime) -> String {
787    let seconds = at.elapsed().map(|age| age.as_secs()).unwrap_or(0);
788    match seconds {
789        0..60 => "just now".to_string(),
790        60..3600 => format!("{} ago", count((seconds / 60) as usize, "minute")),
791        3600..86400 => format!("{} ago", count((seconds / 3600) as usize, "hour")),
792        _ => format!("{} ago", count((seconds / 86400) as usize, "day")),
793    }
794}
795
796/// A lock file, removed when dropped.
797struct Lock(PathBuf);
798
799impl Lock {
800    /// Takes the lock, or `None` while another sync holds it.
801    fn take(dir: &Path) -> Result<Option<Self>> {
802        let path = dir.join(LOCK);
803        for _ in 0..2 {
804            match fs::OpenOptions::new()
805                .write(true)
806                .create_new(true)
807                .open(&path)
808            {
809                Ok(_) => return Ok(Some(Self(path))),
810                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
811                    let stale = fs::metadata(&path)
812                        .and_then(|meta| meta.modified())
813                        .is_ok_and(|at| at.elapsed().is_ok_and(|age| age > STALE_LOCK));
814                    if !stale {
815                        return Ok(None);
816                    }
817                    let _ = fs::remove_file(&path);
818                }
819                Err(error) => {
820                    return Err(error)
821                        .with_context(|| format!("failed to lock {}", path.display()));
822                }
823            }
824        }
825        Ok(None)
826    }
827}
828
829impl Lock {
830    /// Takes the lock, waiting up to `patience` for the sync ahead to finish.
831    fn wait(dir: &Path, patience: Duration) -> Result<Option<Self>> {
832        let started = SystemTime::now();
833        loop {
834            if let Some(lock) = Self::take(dir)? {
835                return Ok(Some(lock));
836            }
837            if started.elapsed().is_ok_and(|waited| waited > patience) {
838                return Ok(None);
839            }
840            std::thread::sleep(Duration::from_millis(200));
841        }
842    }
843}
844
845impl Drop for Lock {
846    fn drop(&mut self) {
847        let _ = fs::remove_file(&self.0);
848    }
849}
850
851#[cfg(test)]
852mod tests {
853    use super::*;
854
855    #[test]
856    fn the_summary_says_what_moved() {
857        assert_eq!(Report::default().summary(), "Already up to date");
858
859        let report = Report {
860            received: 1,
861            sent: 2,
862            kept_both: vec![("a".to_string(), "a-2".to_string())],
863        };
864        let summary = report.summary();
865        assert!(
866            summary.contains("1 change from other machines, 2 changes from this one"),
867            "{summary}"
868        );
869        assert!(summary.contains("now a-2"), "{summary}");
870    }
871
872    #[test]
873    fn a_rejected_push_is_recognised_and_nothing_else_is() {
874        let rejected = anyhow::Error::new(GitError {
875            what: "push".to_string(),
876            message: "! [rejected] HEAD -> main (fetch first)".to_string(),
877        })
878        .context("could not push to the repository");
879        assert!(is_rejected_push(&rejected));
880
881        let unreachable = anyhow::Error::new(GitError {
882            what: "fetch".to_string(),
883            message: "Could not resolve host".to_string(),
884        });
885        assert!(!is_rejected_push(&unreachable));
886    }
887
888    #[test]
889    fn waiting_for_a_held_lock_gives_up_rather_than_hanging() {
890        let dir = env::temp_dir().join(format!("lore-lock-wait-{}", std::process::id()));
891        fs::create_dir_all(&dir).unwrap();
892
893        let held = Lock::take(&dir).unwrap();
894        assert!(held.is_some());
895
896        let waited = Lock::wait(&dir, Duration::from_millis(300)).unwrap();
897        assert!(waited.is_none(), "took a lock somebody else was holding");
898
899        drop(held);
900        assert!(
901            Lock::wait(&dir, Duration::from_millis(300))
902                .unwrap()
903                .is_some()
904        );
905
906        let _ = fs::remove_dir_all(&dir);
907    }
908
909    #[test]
910    fn a_lock_is_exclusive_until_it_is_dropped() {
911        let dir = env::temp_dir().join(format!("lore-lock-{}", std::process::id()));
912        fs::create_dir_all(&dir).unwrap();
913
914        let first = Lock::take(&dir).unwrap();
915        assert!(first.is_some());
916        assert!(Lock::take(&dir).unwrap().is_none());
917        drop(first);
918        assert!(Lock::take(&dir).unwrap().is_some());
919
920        let _ = fs::remove_dir_all(&dir);
921    }
922}