Skip to main content

semora_cli/
checkout.rs

1//! What this checkout last pulled or pushed for each document of its project, so `pull` can
2//! tell a file changed here from one changed on the server, and a keyed push can name the
3//! revision it started from.
4//!
5//! The record is `.semora-state/checkout.json`, next to `.semora`. It belongs to the folder, not
6//! to the machine, so every worktree has its own. The folder holds a `.gitignore` of `*`, so it
7//! never gets committed.
8
9use std::collections::BTreeMap;
10use std::fs::OpenOptions;
11use std::io::ErrorKind;
12use std::path::{Path, PathBuf};
13
14use anyhow::Context;
15use semora_proto::DocKind;
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19use crate::project_link::{Link, shown};
20
21pub const DIR: &str = ".semora-state";
22const FILE: &str = "checkout.json";
23const IGNORE: &str =
24    "# Semora's record of what this folder pulled and pushed. Not for committing.\n*\n";
25
26/// One document as this checkout last saw it.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct Record {
29    pub id: String,
30    pub rev: i64,
31    pub kind: DocKind,
32    /// The sha256 of each file, by its path from the project folder.
33    pub files: BTreeMap<String, String>,
34}
35
36/// The records of a project, by document path.
37pub type Records = BTreeMap<String, Record>;
38
39#[derive(Debug, Default, Serialize, Deserialize)]
40struct Stored {
41    server: String,
42    project: String,
43    #[serde(default)]
44    docs: Records,
45}
46
47pub fn sha256(data: &[u8]) -> String {
48    format!("{:x}", Sha256::digest(data))
49}
50
51fn file(link: &Link) -> PathBuf {
52    link.root.join(DIR).join(FILE)
53}
54
55fn read_stored(link: &Link) -> anyhow::Result<Option<Stored>> {
56    let path = file(link);
57    let text = match std::fs::read_to_string(&path) {
58        Ok(t) => t,
59        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
60        Err(e) => return Err(e).with_context(|| format!("reading {}", shown(&path))),
61    };
62    let stored: Stored = serde_json::from_str(&text).with_context(|| {
63        format!(
64            "{} is unreadable; delete it and run `semora pull` to start the record again",
65            shown(&path)
66        )
67    })?;
68    Ok(Some(stored))
69}
70
71fn read(link: &Link) -> anyhow::Result<Records> {
72    // Records for another server or project (the `.semora` changed) say nothing about this one.
73    Ok(read_stored(link)?
74        .filter(|s| s.server == link.server && s.project == link.slug)
75        .map(|s| s.docs)
76        .unwrap_or_default())
77}
78
79/// The document ids of a record written for this server under another project id: the
80/// `.semora` changed under it, perhaps because another checkout followed the project to a new
81/// id and the change came in with git. Empty when the record is this project's or absent.
82pub fn ids_under_other_id(link: &Link) -> Vec<String> {
83    match read_stored(link) {
84        Ok(Some(s)) if s.server == link.server && s.project != link.slug => {
85            s.docs.into_values().map(|r| r.id).collect()
86        }
87        _ => Vec::new(),
88    }
89}
90
91/// Files the record that was written under another id under this one, keeping its documents.
92pub fn relabel(link: &Link) -> anyhow::Result<()> {
93    let Some(stored) = read_stored(link)? else {
94        return Ok(());
95    };
96    if stored.server != link.server {
97        return Ok(());
98    }
99    let docs = stored.docs;
100    update(link, |r| {
101        for (k, v) in docs {
102            r.entry(k).or_insert(v);
103        }
104    })
105}
106
107/// The records of this checkout's project.
108pub fn load(link: &Link) -> anyhow::Result<Records> {
109    read(link)
110}
111
112/// Carries the records over when the project got a new id, so pull and push still know what
113/// this folder has.
114pub fn move_to(old: &Link, new: &Link) -> anyhow::Result<()> {
115    let docs = read(old)?;
116    if docs.is_empty() {
117        return Ok(());
118    }
119    update(new, |r| {
120        for (k, v) in docs {
121            r.entry(k).or_insert(v);
122        }
123    })
124}
125
126/// Changes the records under a lock, so pushes and pulls in one checkout don't lose each
127/// other's writes.
128pub fn update<T>(link: &Link, change: impl FnOnce(&mut Records) -> T) -> anyhow::Result<T> {
129    let dir = link.root.join(DIR);
130    std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", shown(&dir)))?;
131    let ignore = dir.join(".gitignore");
132    if !ignore.exists() {
133        std::fs::write(&ignore, IGNORE).with_context(|| format!("writing {}", shown(&ignore)))?;
134    }
135    let lock_path = dir.join("lock");
136    let lock = OpenOptions::new()
137        .write(true)
138        .create(true)
139        .truncate(false)
140        .open(&lock_path)
141        .with_context(|| format!("opening {}", shown(&lock_path)))?;
142    lock.lock()
143        .with_context(|| format!("locking {}", shown(&lock_path)))?;
144    let mut docs = read(link)?;
145    let out = change(&mut docs);
146    let stored = Stored {
147        server: link.server.clone(),
148        project: link.slug.clone(),
149        docs,
150    };
151    let text = serde_json::to_string_pretty(&stored).expect("records serialize");
152    // Readers don't lock: the rename swaps in the whole file at once.
153    let tmp = dir.join(format!("{FILE}.tmp"));
154    std::fs::write(&tmp, text).with_context(|| format!("writing {}", shown(&tmp)))?;
155    let path = file(link);
156    std::fs::rename(&tmp, &path).with_context(|| format!("writing {}", shown(&path)))?;
157    Ok(out)
158}
159
160/// A path from the server as a relative path on this machine, or `None` when it would leave
161/// the project folder, reach into `.git`, `.semora` or the record, or can't be a file name here.
162pub fn local_rel(path: &str) -> Option<PathBuf> {
163    if path.is_empty()
164        || path
165            .chars()
166            .any(|c| c.is_control() || c == '\\' || c == ':')
167    {
168        return None;
169    }
170    let mut out = PathBuf::new();
171    for seg in path.split('/') {
172        if seg.is_empty() || seg == "." || seg == ".." {
173            return None;
174        }
175        let lower = seg.to_ascii_lowercase();
176        if [".git", crate::project_link::FILE, DIR].contains(&lower.as_str()) {
177            return None;
178        }
179        if cfg!(windows) && !windows_name(seg) {
180            return None;
181        }
182        out.push(seg);
183    }
184    Some(out)
185}
186
187/// Characters, endings and device names Windows refuses in a file name.
188fn windows_name(seg: &str) -> bool {
189    const DEVICES: [&str; 22] = [
190        "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
191        "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
192    ];
193    let stem = seg.split('.').next().unwrap_or("").to_ascii_lowercase();
194    !seg.contains(['<', '>', '"', '|', '?', '*'])
195        && !seg.ends_with(['.', ' '])
196        && !DEVICES.contains(&stem.as_str())
197}
198
199/// What is on disk at a document's path.
200#[derive(Debug, PartialEq, Eq)]
201pub enum Local {
202    Missing,
203    /// The sha256 of each file a push would send, by path from the project folder.
204    Files(BTreeMap<String, String>),
205    /// A folder where the document is a file, or the other way round.
206    WrongType,
207}
208
209/// Reads the document at `key` as a push would: one file, or the files of a folder.
210pub fn local(root: &Path, key: &str, kind: DocKind) -> anyhow::Result<Local> {
211    let Some(rel) = local_rel(key) else {
212        return Ok(Local::WrongType);
213    };
214    let full = root.join(rel);
215    let meta = match std::fs::metadata(&full) {
216        Ok(m) => m,
217        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Local::Missing),
218        // A file where one of the path's folders should be.
219        Err(e) if e.kind() == ErrorKind::NotADirectory => return Ok(Local::WrongType),
220        Err(e) => return Err(e).with_context(|| format!("reading {}", shown(&full))),
221    };
222    let mut files = BTreeMap::new();
223    match (kind, meta.is_dir()) {
224        (DocKind::Bundle, true) => {
225            let (list, _) = crate::bundle::list(&full)?;
226            for f in list {
227                let data = std::fs::read(full.join(&f))
228                    .with_context(|| format!("reading {}", shown(&full.join(&f))))?;
229                files.insert(format!("{key}/{f}"), sha256(&data));
230            }
231        }
232        (DocKind::Bundle, false) | (DocKind::Md | DocKind::Html, true) => {
233            return Ok(Local::WrongType);
234        }
235        (DocKind::Md | DocKind::Html, false) => {
236            let data = std::fs::read(&full).with_context(|| format!("reading {}", shown(&full)))?;
237            files.insert(key.to_string(), sha256(&data));
238        }
239    }
240    Ok(Local::Files(files))
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    fn link(root: &Path) -> Link {
248        Link {
249            root: root.to_path_buf(),
250            server: "https://a.example.com".into(),
251            slug: "p".into(),
252            file_slug: "p".into(),
253            local: None,
254        }
255    }
256
257    fn record(rev: i64) -> Record {
258        Record {
259            id: "k3F9xQ2mLp".into(),
260            rev,
261            kind: DocKind::Md,
262            files: [("docs/a.md".to_string(), sha256(b"# A"))].into(),
263        }
264    }
265
266    #[test]
267    fn records_round_trip_and_the_folder_ignores_itself() {
268        let d = tempfile::tempdir().unwrap();
269        let l = link(d.path());
270        assert!(load(&l).unwrap().is_empty());
271        update(&l, |r| r.insert("docs/a.md".into(), record(4))).unwrap();
272        assert_eq!(load(&l).unwrap()["docs/a.md"], record(4));
273        let ignore = std::fs::read_to_string(d.path().join(DIR).join(".gitignore")).unwrap();
274        assert!(ignore.lines().any(|l| l == "*"), "{ignore}");
275    }
276
277    #[test]
278    fn records_of_another_project_or_server_are_not_used() {
279        let d = tempfile::tempdir().unwrap();
280        let l = link(d.path());
281        update(&l, |r| r.insert("docs/a.md".into(), record(4))).unwrap();
282        let other = Link {
283            slug: "q".into(),
284            ..l.clone()
285        };
286        assert!(load(&other).unwrap().is_empty());
287        let elsewhere = Link {
288            server: "https://b.example.com".into(),
289            ..l.clone()
290        };
291        assert!(load(&elsewhere).unwrap().is_empty());
292        update(&other, |r| r.insert("x.md".into(), record(1))).unwrap();
293        assert!(
294            load(&l).unwrap().is_empty(),
295            "the file now holds q's records"
296        );
297    }
298
299    #[test]
300    fn an_unreadable_record_says_how_to_start_again() {
301        let d = tempfile::tempdir().unwrap();
302        std::fs::create_dir(d.path().join(DIR)).unwrap();
303        std::fs::write(d.path().join(DIR).join(FILE), "{not json").unwrap();
304        let err = format!("{:#}", load(&link(d.path())).unwrap_err());
305        assert!(err.contains("semora pull") && err.contains(FILE), "{err}");
306    }
307
308    #[test]
309    fn updates_at_the_same_time_keep_every_record() {
310        let d = tempfile::tempdir().unwrap();
311        let l = link(d.path());
312        let n = 16;
313        let start = std::sync::Barrier::new(n);
314        std::thread::scope(|s| {
315            for i in 0..n {
316                let (start, l) = (&start, &l);
317                s.spawn(move || {
318                    start.wait();
319                    update(l, |r| r.insert(format!("f{i}.md"), record(i as i64))).unwrap();
320                });
321            }
322        });
323        assert_eq!(load(&l).unwrap().len(), n);
324    }
325
326    #[test]
327    fn server_paths_stay_inside_the_project_folder() {
328        assert_eq!(
329            local_rel("docs/plan.md"),
330            Some(Path::new("docs").join("plan.md"))
331        );
332        assert!(
333            local_rel(".github/notes.md").is_some(),
334            "a hidden folder is fine"
335        );
336        for bad in [
337            "",
338            "/etc/passwd",
339            "../x.md",
340            "docs/../../x.md",
341            "docs//x.md",
342            "./x.md",
343            "a\\b.md",
344            "C:/x.md",
345            "x\n.md",
346            ".git/hooks/x.md",
347            "sub/.GIT/config.md",
348            ".semora",
349            ".semora-state/checkout.json",
350        ] {
351            assert_eq!(local_rel(bad), None, "{bad:?}");
352        }
353    }
354
355    #[test]
356    fn local_reads_a_file_or_a_folder_as_a_push_would() {
357        let d = tempfile::tempdir().unwrap();
358        let root = d.path();
359        assert_eq!(local(root, "a.md", DocKind::Md).unwrap(), Local::Missing);
360        std::fs::write(root.join("a.md"), "# A").unwrap();
361        assert_eq!(
362            local(root, "a.md", DocKind::Md).unwrap(),
363            Local::Files([("a.md".to_string(), sha256(b"# A"))].into())
364        );
365        assert_eq!(
366            local(root, "a.md", DocKind::Bundle).unwrap(),
367            Local::WrongType
368        );
369        std::fs::create_dir_all(root.join("site/.cache")).unwrap();
370        std::fs::write(root.join("site/index.md"), "# I").unwrap();
371        std::fs::write(root.join("site/.cache/x.md"), "hidden").unwrap();
372        std::fs::write(root.join("site/notes.txt"), "not served").unwrap();
373        assert_eq!(
374            local(root, "site", DocKind::Bundle).unwrap(),
375            Local::Files([("site/index.md".to_string(), sha256(b"# I"))].into()),
376            "only what a push would send"
377        );
378        assert_eq!(local(root, "site", DocKind::Md).unwrap(), Local::WrongType);
379        assert_eq!(
380            local(root, "../a.md", DocKind::Md).unwrap(),
381            Local::WrongType
382        );
383    }
384}