Skip to main content

git_policy_cli/
lfs_object_plan.rs

1//! What:
2//!  Narrow an upload plan from "scan these refs with `git-lfs`" to the exact objects the push introduces, decided in
3//!  process, or to nothing.
4//! Why:
5//!  Most pushes add no LFS object, and then no `git-lfs` process and no request to the LFS server is needed. The tips come
6//!  from the plan's refs; the commits already known to the destination are the remote's tracking refs when the plan
7//!  trusts them, else none (the whole history of the pushed refs is scanned, which keeps the case where the tracking
8//!  refs may not describe the destination correct). When the readers decline, or an object the scan found is missing
9//!  from `.git/lfs` (where `git-lfs push` words the failure and honours `lfs.allowincompletepush`), the plan stays as it
10//!  was and `git-lfs` scans.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! const narrowed = narrowPlan(plan, layout); // undefined: nothing to upload; same plan: ask git-lfs
15//! ```
16
17use super::lfs_upload_plan::UploadPlan;
18use super::repo_read_layout::RepoLayout;
19
20/// What:
21///  The plan to run: the same plan when the readers decline, the plan with its objects named when the scan found some,
22///  nothing when it found none.
23/// Why:
24///   See the module text. Non-Unix hosts have no readers and keep the plan.
25///
26/// In TS you'd write (pseudocode):
27/// ```ts
28/// function narrowPlan(plan: UploadPlan, layout?: RepoLayout): UploadPlan | undefined;
29/// ```
30pub fn narrow_plan(plan: UploadPlan, layout: Option<&RepoLayout>) -> Option<UploadPlan> {
31    #[cfg(unix)]
32    {
33        let Some(found) = layout else {
34            return Some(plan);
35        };
36        return match unix::scan(&plan, found) {
37            None => Some(plan),
38            Some(objects) if objects.is_empty() => None,
39            Some(objects) => Some(UploadPlan { objects: Some(objects), ..plan }),
40        };
41    }
42    #[cfg(not(unix))]
43    {
44        let _unused = layout;
45        return Some(plan);
46    }
47}
48
49/// The readers' side of the narrowing.
50#[cfg(unix)]
51mod unix {
52    use super::super::diagnostic_log::debug;
53    use super::super::lfs_pointer_scan::{SCAN_BUDGET, new_pointer_objects};
54    use super::super::lfs_upload_plan::UploadPlan;
55    use super::super::repo_read_layout::RepoLayout;
56    use super::super::repo_read_object::{ObjectKind, from_hex, name_length, to_hex};
57    use super::super::repo_read_odb::ObjectDatabase;
58    use super::super::repo_read_refs::{Lookup, RefValue, Refs, is_plain_ref_name};
59
60    /// The tag of the decision lines.
61    const TAG: &str = "cli-git lfs-object-plan";
62
63    /// How many tag objects are followed to a commit.
64    const PEEL_LIMIT: usize = 5;
65
66    /// The commit a name stands for, through annotated tags; `None` for anything else.
67    fn peel(database: &ObjectDatabase, layout: &RepoLayout, hex: &str) -> Option<Vec<u8>> {
68        let mut name: Vec<u8> = from_hex(hex.as_bytes(), layout.object_format)?;
69        for _ in 0..PEEL_LIMIT {
70            let object = database.read(name.as_slice())?;
71            match object.kind {
72                ObjectKind::Commit => return Some(name),
73                ObjectKind::Tag => {
74                    let first: &[u8] = object.bytes.split(|byte| { return *byte == b'\n'; }).next()?;
75                    name = from_hex(first.strip_prefix(b"object ")?, layout.object_format)?;
76                }
77                ObjectKind::Tree | ObjectKind::Blob => return None,
78            }
79        }
80        return None;
81    }
82
83    /// The hexadecimal name a typed ref (`main`, `refs/tags/v1`, `HEAD`, a full object name) stands for, by Git's order.
84    fn tip_of(database: &ObjectDatabase, layout: &RepoLayout, refs: &Refs, typed: &str) -> Option<String> {
85        if typed == "HEAD" {
86            return match refs.lookup_head(layout.git_dir.as_path())? {
87                RefValue::Direct(hex) => Some(hex),
88                RefValue::Symbolic(target) => refs.resolve(target.as_str()),
89            };
90        }
91        let plain: bool = !typed.is_empty()
92            && typed.split('/').all(|part| {
93                return !part.is_empty() && part.bytes().all(|byte| { return byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'); });
94            });
95        if !plain {
96            return None;
97        }
98        let candidates: [String; 6] = [
99            typed.to_owned(),
100            format!("refs/{typed}"),
101            format!("refs/tags/{typed}"),
102            format!("refs/heads/{typed}"),
103            format!("refs/remotes/{typed}"),
104            format!("refs/remotes/{typed}/HEAD"),
105        ];
106        for candidate in &candidates {
107            if !is_plain_ref_name(candidate) {
108                continue;
109            }
110            match refs.lookup(candidate) {
111                Lookup::Found(_) => return refs.resolve(candidate),
112                Lookup::Missing => {}
113                Lookup::Unsure => return None,
114            }
115        }
116        let as_name: Vec<u8> = from_hex(typed.as_bytes(), layout.object_format)?;
117        return database.contains(as_name.as_slice()).then(|| { return to_hex(as_name.as_slice()); });
118    }
119
120    /// The names of the LFS objects the plan's push introduces; `None` when the readers decline.
121    pub(super) fn scan(plan: &UploadPlan, layout: &RepoLayout) -> Option<Vec<String>> {
122        let database: ObjectDatabase = ObjectDatabase::open(layout.common_dir.as_path(), None, layout.object_format)?;
123        let refs: Refs = Refs::new(layout.common_dir.as_path(), layout.object_format.hex_length());
124        let listing = refs.list()?;
125        let mut tips: Vec<String> = Vec::new();
126        if plan.everything {
127            for (_, value) in &listing {
128                if let RefValue::Direct(hex) = value {
129                    tips.push(hex.clone());
130                }
131            }
132        } else {
133            for typed in &plan.refs {
134                tips.push(tip_of(&database, layout, &refs, typed.to_str()?)?);
135            }
136        }
137        let mut include: Vec<Vec<u8>> = Vec::new();
138        for hex in &tips {
139            include.push(peel(&database, layout, hex)?);
140        }
141        let mut stop: Vec<Vec<u8>> = Vec::new();
142        if !plan.full_scan {
143            let remote: &str = plan.remotes.first()?.to_str()?;
144            let prefix: String = format!("refs/remotes/{remote}/");
145            for (name, value) in &listing {
146                if let (true, RefValue::Direct(hex)) = (name.starts_with(prefix.as_str()), value) {
147                    stop.push(from_hex(hex.as_bytes(), layout.object_format)?);
148                }
149            }
150        }
151        let objects: Vec<String> = new_pointer_objects(&database, layout.object_format, include.as_slice(), stop.as_slice(), SCAN_BUDGET)?;
152        for name in &objects {
153            let stored = layout.common_dir.join("lfs/objects").join(&name[..2]).join(&name[2..4]).join(name);
154            if !stored.is_file() {
155                debug(TAG, &format!("object {name} is not in the local LFS storage; asking git-lfs"));
156                return None;
157            }
158        }
159        debug(
160            TAG,
161            &format!(
162                "{} tips, {} known tips, full scan {}: {} objects to upload (hash length {})",
163                include.len(),
164                stop.len(),
165                plan.full_scan,
166                objects.len(),
167                name_length(layout.object_format)
168            ),
169        );
170        return Some(objects);
171    }
172}
173
174/// The narrowing is controlled in the test module.
175#[cfg(test)]
176#[path = "lfs_object_plan_tests.rs"]
177mod tests;