anvilsign in

collin/anvil

1use std::path::Path;
2
3use crate::error::Result;
4use crate::pktline;
5
6const CAPABILITIES: &str = "multi_ack multi_ack_detailed side-band-64k ofs-delta";
7
8const ZERO_OID: &str = "0000000000000000000000000000000000000000";
9
10/// Generate the complete pkt-line encoded ref advertisement response
11/// for the Git smart HTTP protocol (`git-upload-pack`).
12///
13/// The output follows the format expected by `git clone` / `git fetch`:
14///
15/// ```text
16/// 001e# service=git-upload-pack\n
17/// 0000
18/// <first-ref>\0<capabilities>\n
19/// <ref-line>\n
20/// ...
21/// 0000
22/// ```
23pub fn advertise_refs(repo_path: &Path) -> Result<Vec<u8>> {
24 let repo = gix::open(repo_path)?;
25
26 let mut output = Vec::new();
27
28 // Service header + flush
29 output.extend_from_slice(&pktline::encode_comment("service=git-upload-pack"));
30 output.extend_from_slice(pktline::flush());
31
32 // Collect refs: (oid_hex, refname)
33 let mut refs: Vec<(String, String)> = Vec::new();
34
35 // Try to resolve HEAD first
36 if let Ok(id) = repo.head_id() {
37 refs.push((id.to_string(), "HEAD".to_string()));
38 }
39
40 // Iterate all references
41 if let Ok(platform) = repo.references()
42 && let Ok(iter) = platform.all()
43 {
44 for mut r in iter.flatten() {
45 let name = r.name().as_bstr().to_string();
46 if let Ok(id) = r.peel_to_id() {
47 refs.push((id.to_string(), name));
48 }
49 }
50 }
51
52 if refs.is_empty() {
53 // No refs at all: advertise capabilities with zero OID
54 let line = format!("{ZERO_OID} capabilities^{{}}\0{CAPABILITIES}\n");
55 output.extend_from_slice(&pktline::encode(line.as_bytes()));
56 } else {
57 // First ref line includes capabilities after NUL byte
58 let (oid, name) = &refs[0];
59 let first_line = format!("{oid} {name}\0{CAPABILITIES}\n");
60 output.extend_from_slice(&pktline::encode(first_line.as_bytes()));
61
62 // Subsequent ref lines
63 for (oid, name) in &refs[1..] {
64 let line = format!("{oid} {name}\n");
65 output.extend_from_slice(&pktline::encode(line.as_bytes()));
66 }
67 }
68
69 // Final flush
70 output.extend_from_slice(pktline::flush());
71
72 Ok(output)
73}
74
75#[cfg(test)]
76mod tests {
77 use std::path::{Path, PathBuf};
78 use std::process::Command;
79
80 use tempfile::TempDir;
81
82 use super::*;
83
84 /// Create a bare repo with a single commit on the `main` branch.
85 ///
86 /// Returns the path to the bare repository.
87 fn create_repo_with_commit(root: &Path) -> PathBuf {
88 let bare_path = root.join("test.git");
89 let clone_path = root.join("workdir");
90
91 // Create a bare repository
92 let out = Command::new("git")
93 .args(["init", "--bare", bare_path.to_str().unwrap()])
94 .output()
95 .expect("git init --bare failed");
96 assert!(out.status.success(), "git init --bare failed: {:?}", out);
97
98 // Set default branch to main in bare repo
99 let out = Command::new("git")
100 .args(["symbolic-ref", "HEAD", "refs/heads/main"])
101 .current_dir(&bare_path)
102 .output()
103 .expect("git symbolic-ref failed");
104 assert!(out.status.success());
105
106 // Clone the bare repo into a working directory
107 let out = Command::new("git")
108 .args([
109 "clone",
110 bare_path.to_str().unwrap(),
111 clone_path.to_str().unwrap(),
112 ])
113 .output()
114 .expect("git clone failed");
115 assert!(out.status.success(), "git clone failed: {:?}", out);
116
117 // Configure user in the clone
118 for (key, val) in [("user.name", "Test User"), ("user.email", "test@test.com")] {
119 Command::new("git")
120 .args(["config", key, val])
121 .current_dir(&clone_path)
122 .output()
123 .expect("git config failed");
124 }
125
126 // Create a commit
127 let out = Command::new("git")
128 .args(["commit", "--allow-empty", "-m", "initial commit"])
129 .current_dir(&clone_path)
130 .env("GIT_AUTHOR_NAME", "Test User")
131 .env("GIT_AUTHOR_EMAIL", "test@test.com")
132 .env("GIT_COMMITTER_NAME", "Test User")
133 .env("GIT_COMMITTER_EMAIL", "test@test.com")
134 .output()
135 .expect("git commit failed");
136 assert!(out.status.success(), "git commit failed: {:?}", out);
137
138 // Push to the bare repo
139 let out = Command::new("git")
140 .args(["push", "origin", "main"])
141 .current_dir(&clone_path)
142 .output()
143 .expect("git push failed");
144 assert!(out.status.success(), "git push failed: {:?}", out);
145
146 bare_path
147 }
148
149 #[test]
150 fn advertise_refs_starts_with_service_header() {
151 let dir = TempDir::new().unwrap();
152 let repo_path = create_repo_with_commit(dir.path());
153 let output = advertise_refs(&repo_path).unwrap();
154 let output_str = String::from_utf8_lossy(&output);
155
156 assert!(
157 output_str.starts_with("001e# service=git-upload-pack\n0000"),
158 "output does not start with service header: {output_str:?}"
159 );
160 }
161
162 #[test]
163 fn advertise_refs_contains_capabilities() {
164 let dir = TempDir::new().unwrap();
165 let repo_path = create_repo_with_commit(dir.path());
166 let output = advertise_refs(&repo_path).unwrap();
167 let output_str = String::from_utf8_lossy(&output);
168
169 assert!(
170 output_str.contains("multi_ack"),
171 "output missing multi_ack: {output_str:?}"
172 );
173 assert!(
174 output_str.contains("multi_ack_detailed"),
175 "output missing multi_ack_detailed: {output_str:?}"
176 );
177 assert!(
178 output_str.contains("side-band-64k"),
179 "output missing side-band-64k: {output_str:?}"
180 );
181 assert!(
182 output_str.contains("ofs-delta"),
183 "output missing ofs-delta: {output_str:?}"
184 );
185 assert!(
186 !output_str.contains("thin-pack"),
187 "output should not advertise thin-pack: {output_str:?}"
188 );
189 assert!(
190 !output_str.contains("shallow"),
191 "output should not advertise shallow: {output_str:?}"
192 );
193 }
194
195 #[test]
196 fn advertise_refs_contains_main_branch() {
197 let dir = TempDir::new().unwrap();
198 let repo_path = create_repo_with_commit(dir.path());
199 let output = advertise_refs(&repo_path).unwrap();
200 let output_str = String::from_utf8_lossy(&output);
201
202 assert!(
203 output_str.contains("refs/heads/main"),
204 "output missing refs/heads/main: {output_str:?}"
205 );
206 }
207
208 #[test]
209 fn advertise_refs_ends_with_flush() {
210 let dir = TempDir::new().unwrap();
211 let repo_path = create_repo_with_commit(dir.path());
212 let output = advertise_refs(&repo_path).unwrap();
213
214 assert!(
215 output.ends_with(b"0000"),
216 "output does not end with flush packet: {:?}",
217 String::from_utf8_lossy(&output)
218 );
219 }
220}