Skip to main content

sley_remote/
shallow.rs

1//! Reading and updating the repository's `$GIT_DIR/shallow` boundary file.
2//!
3//! A shallow repository records the object ids of the commits at the edge of its
4//! truncated history in `$GIT_DIR/shallow`: one full hex oid per line, sorted as
5//! git writes it (lexicographically by hex, which equals binary oid order). The
6//! file is absent for a complete (non-shallow) repository.
7//!
8//! [`read_shallow`] loads the boundary the client must replay as `shallow` lines
9//! in a deepen request; [`apply_shallow_info`] folds the server's shallow-info
10//! response ([`ProtocolV2FetchShallowInfo`]) back into the file after the pack is
11//! installed — adding `shallow` entries and dropping `unshallow` ones — and
12//! removes the file when the repository becomes complete, matching git.
13
14use std::fs;
15use std::path::Path;
16
17use sley_core::{GitError, ObjectFormat, ObjectId, Result};
18use sley_protocol::ProtocolV2FetchShallowInfo;
19
20/// The boundary commit ids recorded in `$GIT_DIR/shallow`, or an empty vec when
21/// the file is absent (a complete repository). Lines are parsed as full hex oids
22/// of `format`; blank lines are ignored.
23pub fn read_shallow(git_dir: &Path, format: ObjectFormat) -> Result<Vec<ObjectId>> {
24    let path = git_dir.join("shallow");
25    let contents = match fs::read_to_string(&path) {
26        Ok(contents) => contents,
27        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
28        Err(err) => return Err(err.into()),
29    };
30    let mut oids = Vec::new();
31    for line in contents.lines() {
32        let line = line.trim();
33        if line.is_empty() {
34            continue;
35        }
36        oids.push(ObjectId::from_hex(format, line).map_err(|err| {
37            GitError::InvalidFormat(format!("invalid oid in {}: {err}", path.display()))
38        })?);
39    }
40    Ok(oids)
41}
42
43/// Write `$GIT_DIR/shallow` from `oids`, sorting and de-duplicating them the way
44/// git does (by hex, one per line, trailing newline). An empty set removes the
45/// file so the repository reads as complete.
46pub fn write_shallow(git_dir: &Path, oids: &[ObjectId]) -> Result<()> {
47    let path = git_dir.join("shallow");
48    let mut hexes = oids.iter().map(ObjectId::to_hex).collect::<Vec<_>>();
49    hexes.sort();
50    hexes.dedup();
51    if hexes.is_empty() {
52        match fs::remove_file(&path) {
53            Ok(()) => {}
54            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
55            Err(err) => return Err(err.into()),
56        }
57        return Ok(());
58    }
59    let mut contents = String::with_capacity(hexes.iter().map(|hex| hex.len() + 1).sum());
60    for hex in &hexes {
61        contents.push_str(hex);
62        contents.push('\n');
63    }
64    fs::write(&path, contents)?;
65    Ok(())
66}
67
68/// Fold the server's shallow-info `entries` into `$GIT_DIR/shallow`: the new
69/// boundary is the existing set plus every `shallow <oid>` minus every
70/// `unshallow <oid>`. A no-op when `entries` is empty (so a deepen request that
71/// reported no boundary change leaves the file untouched). Mirrors git's update
72/// of the shallow file after a deepen fetch.
73pub fn apply_shallow_info(
74    git_dir: &Path,
75    format: ObjectFormat,
76    entries: &[ProtocolV2FetchShallowInfo],
77) -> Result<()> {
78    if entries.is_empty() {
79        return Ok(());
80    }
81    let mut oids = read_shallow(git_dir, format)?;
82    for entry in entries {
83        match entry {
84            ProtocolV2FetchShallowInfo::Shallow(oid) => {
85                if !oids.contains(oid) {
86                    oids.push(*oid);
87                }
88            }
89            ProtocolV2FetchShallowInfo::Unshallow(oid) => oids.retain(|existing| existing != oid),
90        }
91    }
92    write_shallow(git_dir, &oids)
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use std::sync::atomic::{AtomicU64, Ordering};
99
100    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
101
102    fn temp_dir() -> std::path::PathBuf {
103        let dir = std::env::temp_dir().join(format!(
104            "sley-remote-shallow-{}-{}",
105            std::process::id(),
106            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
107        ));
108        let _ = fs::remove_dir_all(&dir);
109        fs::create_dir_all(&dir).expect("create temp dir");
110        dir
111    }
112
113    fn oid(hex_byte: &str) -> ObjectId {
114        ObjectId::from_hex(ObjectFormat::Sha1, &hex_byte.repeat(40)).expect("valid oid")
115    }
116
117    #[test]
118    fn read_missing_shallow_is_empty() {
119        let dir = temp_dir();
120        assert!(
121            read_shallow(&dir, ObjectFormat::Sha1)
122                .expect("test operation should succeed")
123                .is_empty()
124        );
125        let _ = fs::remove_dir_all(&dir);
126    }
127
128    #[test]
129    fn write_sorts_dedups_and_round_trips() {
130        let dir = temp_dir();
131        let a = oid("1");
132        let b = oid("2");
133        write_shallow(&dir, &[b.clone(), a.clone(), b.clone()])
134            .expect("test operation should succeed");
135        let contents =
136            fs::read_to_string(dir.join("shallow")).expect("test operation should succeed");
137        assert_eq!(contents, format!("{}\n{}\n", a.to_hex(), b.to_hex()));
138        assert_eq!(
139            read_shallow(&dir, ObjectFormat::Sha1).expect("test operation should succeed"),
140            vec![a, b]
141        );
142        let _ = fs::remove_dir_all(&dir);
143    }
144
145    #[test]
146    fn write_empty_removes_file() {
147        let dir = temp_dir();
148        write_shallow(&dir, &[oid("3")]).expect("test operation should succeed");
149        assert!(dir.join("shallow").exists());
150        write_shallow(&dir, &[]).expect("test operation should succeed");
151        assert!(!dir.join("shallow").exists());
152        // Removing an already-absent file is fine.
153        write_shallow(&dir, &[]).expect("test operation should succeed");
154        let _ = fs::remove_dir_all(&dir);
155    }
156
157    #[test]
158    fn apply_adds_shallow_and_drops_unshallow() {
159        let dir = temp_dir();
160        let keep = oid("a");
161        let added = oid("b");
162        let removed = oid("c");
163        write_shallow(&dir, &[keep.clone(), removed.clone()])
164            .expect("test operation should succeed");
165        apply_shallow_info(
166            &dir,
167            ObjectFormat::Sha1,
168            &[
169                ProtocolV2FetchShallowInfo::Shallow(added.clone()),
170                ProtocolV2FetchShallowInfo::Unshallow(removed),
171            ],
172        )
173        .expect("test operation should succeed");
174        // The file is written sorted by hex, so `read_shallow` returns
175        // `keep` (aaaa…) before `added` (bbbb…), with `removed` (cccc…) gone.
176        assert_eq!(
177            read_shallow(&dir, ObjectFormat::Sha1).expect("test operation should succeed"),
178            vec![keep, added]
179        );
180        let _ = fs::remove_dir_all(&dir);
181    }
182
183    #[test]
184    fn apply_empty_entries_is_noop() {
185        let dir = temp_dir();
186        let existing = oid("d");
187        write_shallow(&dir, std::slice::from_ref(&existing))
188            .expect("test operation should succeed");
189        apply_shallow_info(&dir, ObjectFormat::Sha1, &[]).expect("test operation should succeed");
190        assert_eq!(
191            read_shallow(&dir, ObjectFormat::Sha1).expect("test operation should succeed"),
192            vec![existing]
193        );
194        let _ = fs::remove_dir_all(&dir);
195    }
196
197    #[test]
198    fn apply_unshallowing_last_boundary_removes_file() {
199        let dir = temp_dir();
200        let boundary = oid("e");
201        write_shallow(&dir, std::slice::from_ref(&boundary))
202            .expect("test operation should succeed");
203        apply_shallow_info(
204            &dir,
205            ObjectFormat::Sha1,
206            &[ProtocolV2FetchShallowInfo::Unshallow(boundary)],
207        )
208        .expect("test operation should succeed");
209        assert!(!dir.join("shallow").exists());
210        let _ = fs::remove_dir_all(&dir);
211    }
212}