guts_git/
protocol.rs

1//! Git smart HTTP protocol implementation.
2//!
3//! Implements the git smart HTTP protocol for fetch and push operations.
4//! See: https://git-scm.com/docs/http-protocol
5
6use crate::pack::{PackBuilder, PackParser};
7use crate::pktline::{PktLine, PktLineReader, PktLineWriter};
8use crate::Result;
9use guts_storage::{ObjectId, ObjectStore, Reference, Repository};
10use std::io::{Read, Write};
11
12/// Git capabilities we advertise.
13const CAPABILITIES: &str =
14    "report-status delete-refs side-band-64k quiet ofs-delta agent=guts/0.1.0";
15
16/// A reference advertisement line.
17#[derive(Debug, Clone)]
18pub struct RefAdvertisement {
19    /// Object ID the ref points to.
20    pub id: ObjectId,
21    /// Reference name.
22    pub name: String,
23}
24
25/// Advertises references to a client (for fetch/clone).
26pub fn advertise_refs<W: Write>(writer: &mut W, repo: &Repository, service: &str) -> Result<()> {
27    let mut pkt_writer = PktLineWriter::new(writer);
28
29    // Get all refs
30    let refs = repo.refs.list_all();
31    let head = repo.refs.resolve_head().ok();
32
33    // First line includes capabilities
34    let first_ref = if let Some(head_id) = head {
35        format!("{} HEAD\0{}\n", head_id, CAPABILITIES)
36    } else if let Some((name, Reference::Direct(id))) = refs.first() {
37        format!("{} {}\0{}\n", id, name, CAPABILITIES)
38    } else {
39        // Empty repo - use zero ID
40        let zero_id = "0000000000000000000000000000000000000000";
41        format!("{} capabilities^{{}}\0{}\n", zero_id, CAPABILITIES)
42    };
43
44    pkt_writer.write(&PktLine::from_string(&format!("# service={}\n", service)))?;
45    pkt_writer.flush_pkt()?;
46
47    pkt_writer.write(&PktLine::from_string(&first_ref))?;
48
49    // Write remaining refs
50    for (name, reference) in &refs {
51        if let Reference::Direct(id) = reference {
52            pkt_writer.write_line(&format!("{} {}", id, name))?;
53        }
54    }
55
56    pkt_writer.flush_pkt()?;
57    pkt_writer.flush()?;
58
59    Ok(())
60}
61
62/// Want/Have negotiation for upload-pack.
63#[derive(Debug, Clone)]
64pub struct WantHave {
65    /// Object IDs the client wants.
66    pub wants: Vec<ObjectId>,
67    /// Object IDs the client has (for delta compression).
68    pub haves: Vec<ObjectId>,
69}
70
71impl WantHave {
72    /// Parses want/have lines from the client.
73    pub fn parse<R: Read>(reader: &mut R) -> Result<Self> {
74        let mut pkt_reader = PktLineReader::new(reader);
75        let mut wants = Vec::new();
76        let mut haves = Vec::new();
77
78        // Read wants
79        loop {
80            match pkt_reader.read()? {
81                Some(PktLine::Data(data)) => {
82                    let line = String::from_utf8_lossy(&data);
83                    let line = line.trim();
84
85                    if line.starts_with("want ") {
86                        let id_str = &line[5..45];
87                        wants.push(ObjectId::from_hex(id_str)?);
88                    } else if line.starts_with("have ") {
89                        let id_str = &line[5..45];
90                        haves.push(ObjectId::from_hex(id_str)?);
91                    } else if line == "done" {
92                        break;
93                    }
94                }
95                Some(PktLine::Flush) => {
96                    // After wants, read haves
97                    continue;
98                }
99                _ => break,
100            }
101        }
102
103        Ok(Self { wants, haves })
104    }
105}
106
107/// Handles git-upload-pack (fetch/clone).
108pub fn upload_pack<R: Read, W: Write>(
109    reader: &mut R,
110    writer: &mut W,
111    repo: &Repository,
112) -> Result<()> {
113    let want_have = WantHave::parse(reader)?;
114    let mut pkt_writer = PktLineWriter::new(writer);
115
116    if want_have.wants.is_empty() {
117        pkt_writer.write_line("NAK")?;
118        pkt_writer.flush()?;
119        return Ok(());
120    }
121
122    // Build pack with requested objects
123    let mut builder = PackBuilder::new();
124
125    for want_id in &want_have.wants {
126        // Add the wanted object and its dependencies
127        collect_objects(&repo.objects, want_id, &want_have.haves, &mut builder)?;
128    }
129
130    let pack = builder.build()?;
131
132    // Send ACK and pack data
133    pkt_writer.write_line("NAK")?; // We don't do common ancestor negotiation yet
134
135    // Send pack via side-band
136    // Side-band channel 1 is for pack data
137    for chunk in pack.chunks(65515) {
138        // Max side-band payload
139        let mut data = vec![1u8]; // Channel 1
140        data.extend_from_slice(chunk);
141        pkt_writer.write(&PktLine::Data(data))?;
142    }
143
144    pkt_writer.flush_pkt()?;
145    pkt_writer.flush()?;
146
147    Ok(())
148}
149
150/// Collects an object and its dependencies for packing.
151fn collect_objects(
152    store: &ObjectStore,
153    id: &ObjectId,
154    have: &[ObjectId],
155    builder: &mut PackBuilder,
156) -> Result<()> {
157    // Skip if client already has this object
158    if have.contains(id) {
159        return Ok(());
160    }
161
162    if let Ok(object) = store.get(id) {
163        builder.add(object.clone());
164
165        // For commits, also collect tree and parents
166        if object.object_type == guts_storage::ObjectType::Commit {
167            // Parse commit to find tree and parents
168            let content = String::from_utf8_lossy(&object.data);
169            for line in content.lines() {
170                if let Some(tree_hex) = line.strip_prefix("tree ") {
171                    if let Ok(tree_id) = ObjectId::from_hex(tree_hex) {
172                        collect_objects(store, &tree_id, have, builder)?;
173                    }
174                } else if let Some(parent_hex) = line.strip_prefix("parent ") {
175                    if let Ok(parent_id) = ObjectId::from_hex(parent_hex) {
176                        collect_objects(store, &parent_id, have, builder)?;
177                    }
178                } else if line.is_empty() {
179                    break; // End of headers
180                }
181            }
182        }
183        // For trees, collect blobs and subtrees
184        else if object.object_type == guts_storage::ObjectType::Tree {
185            // Parse tree entries (simplified - real git uses binary format)
186            // For MVP, we'll handle this when we implement proper tree serialization
187        }
188    }
189
190    Ok(())
191}
192
193/// A ref update command from the client.
194#[derive(Debug, Clone)]
195pub struct Command {
196    /// Old object ID (zeros for create).
197    pub old_id: ObjectId,
198    /// New object ID (zeros for delete).
199    pub new_id: ObjectId,
200    /// Reference name.
201    pub ref_name: String,
202}
203
204impl Command {
205    /// Checks if this is a create command.
206    pub fn is_create(&self) -> bool {
207        self.old_id.to_hex() == "0000000000000000000000000000000000000000"
208    }
209
210    /// Checks if this is a delete command.
211    pub fn is_delete(&self) -> bool {
212        self.new_id.to_hex() == "0000000000000000000000000000000000000000"
213    }
214}
215
216/// Handles git-receive-pack (push).
217pub fn receive_pack<R: Read, W: Write>(
218    reader: &mut R,
219    writer: &mut W,
220    repo: &Repository,
221) -> Result<Vec<Command>> {
222    let mut pkt_reader = PktLineReader::new(reader);
223    let mut commands = Vec::new();
224
225    // Read commands
226    loop {
227        match pkt_reader.read()? {
228            Some(PktLine::Data(data)) => {
229                let line = String::from_utf8_lossy(&data);
230                let line = line.trim();
231
232                // Parse: old-id new-id ref-name
233                let parts: Vec<&str> = line.splitn(3, ' ').collect();
234                if parts.len() >= 3 {
235                    let old_id = ObjectId::from_hex(parts[0])?;
236                    let new_id = ObjectId::from_hex(parts[1])?;
237                    let ref_name = parts[2].split('\0').next().unwrap_or(parts[2]).to_string();
238
239                    commands.push(Command {
240                        old_id,
241                        new_id,
242                        ref_name,
243                    });
244                }
245            }
246            Some(PktLine::Flush) | None => break,
247            _ => continue,
248        }
249    }
250
251    // Read pack data
252    let mut pack_data = Vec::new();
253    // Read remaining data directly from the underlying reader
254    pkt_reader.inner_mut().read_to_end(&mut pack_data)?;
255
256    if !pack_data.is_empty() {
257        // Parse pack and store objects
258        let mut parser = PackParser::new(&pack_data);
259        parser.parse(&repo.objects)?;
260    }
261
262    // Apply ref updates
263    for cmd in &commands {
264        if cmd.is_delete() {
265            let _ = repo.refs.delete(&cmd.ref_name);
266        } else {
267            repo.refs.set(&cmd.ref_name, cmd.new_id);
268        }
269    }
270
271    // Send status report
272    let mut pkt_writer = PktLineWriter::new(writer);
273    pkt_writer.write_line("unpack ok")?;
274    for cmd in &commands {
275        pkt_writer.write_line(&format!("ok {}", cmd.ref_name))?;
276    }
277    pkt_writer.flush_pkt()?;
278    pkt_writer.flush()?;
279
280    Ok(commands)
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn test_ref_advertisement() {
289        let repo = Repository::new("test", "alice");
290
291        // Add an object and ref
292        let blob = guts_storage::GitObject::blob(b"test".to_vec());
293        let id = repo.objects.put(blob);
294        repo.refs.set("refs/heads/main", id);
295
296        let mut output = Vec::new();
297        advertise_refs(&mut output, &repo, "git-upload-pack").unwrap();
298
299        let output_str = String::from_utf8_lossy(&output);
300        assert!(output_str.contains("git-upload-pack"));
301        assert!(output_str.contains(&id.to_hex()));
302    }
303}