Skip to content

Commit e60220d

Browse files
make it work with remote repos
1. lfs/config.rs — Added resolve_remote() - New async method that parses remote_url → RemoteRepository via api::client::repositories::get_by_url() - Returns Ok(None) when no remote is configured, errors if URL is set but repo doesn't exist 2. api/client/versions.rs — Generic download_versions_to_store() - Extracted the HTTP QUERY + gzip + tar extraction logic into try_download_versions_to_store() that takes &dyn VersionStore - Added public download_versions_to_store() with retry wrapper (same retry logic as original) - Refactored try_download_data_from_version_paths to delegate — zero behavior change for existing callers 3. lfs/sync.rs — git_add() error handling - Changed return type from () to Result<(), OxenError> - Propagates spawn errors, logs non-zero exit as warning (non-fatal) - Updated both call sites to use ? 4. lfs/sync.rs — Real push_to_remote() implementation - Loads LfsConfig, resolves remote — skips if no remote configured - Builds a temp staging dir with files hard-linked/copied from version store at their real repo-relative paths - Creates workspace → add_files → commit via workspace API - On error, attempts workspace cleanup via delete - Renamed _args → hook_args and logs for debugging 5. lfs/sync.rs — Real pull_from_remote() with remote download - After local + origin resolution, collects still-missing OIDs into need_remote - If !local_only and remote is configured, batch-downloads via download_versions_to_store() - Restores downloaded files to working tree and runs git_add() 6. lfs/sync.rs — fetch_all() updated for remote - After local+origin resolution, tries configured Oxen remote for unresolved pointers - Only errors if pointers remain unresolved AND no remote is available 7. lfs/filter.rs — Remote fetch in smudge() with 30s timeout - Renamed _lfs_config → lfs_config - After local + origin checks, attempts remote fetch wrapped in tokio::time::timeout(30s) - On success, reads from local store; on timeout/error, falls through to pointer fallback 8. lfs/filter_process.rs — Documented _caps - Added comment explaining why capabilities are read but unused 9. Tests (4 new, all passing) - test_push_no_remote_configured — succeeds silently with no remote - test_pull_local_only_no_network — restores local content, doesn't attempt network - test_git_add_returns_result — propagates errors properly - test_smudge_remote_fallback_on_no_server — falls back gracefully when remote unreachable Verification - cargo clippy --no-deps -- -D warnings — clean - cargo test --lib lfs — 44 tests passed, 0 failed
1 parent 59a80a9 commit e60220d

File tree

5 files changed

+365
-37
lines changed

5 files changed

+365
-37
lines changed

oxen-rust/src/lib/src/api/client/versions.rs

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use crate::constants::{max_retries, AVG_CHUNK_SIZE};
55
use crate::error::OxenError;
66
use crate::model::entry::commit_entry::Entry;
77
use crate::model::{LocalRepository, MerkleHash, RemoteRepository};
8+
use crate::storage::version_store::VersionStore;
89
use crate::util::{self, concurrency, hasher};
910
use crate::view::versions::{
1011
CleanCorruptedVersionsResponse, CompleteVersionUploadRequest, CompletedFileUpload,
@@ -226,15 +227,56 @@ pub async fn try_download_data_from_version_paths(
226227
remote_repo: &RemoteRepository,
227228
hashes: &[String],
228229
local_repo: &LocalRepository,
230+
) -> Result<u64, OxenError> {
231+
let version_store = local_repo.version_store()?;
232+
try_download_versions_to_store(remote_repo, hashes, version_store.as_ref()).await
233+
}
234+
235+
/// Generic batch download of version blobs into any [`VersionStore`].
236+
///
237+
/// Sends the requested hashes to the server, receives a gzip+tar archive,
238+
/// and streams each entry into `version_store` via `store_version_from_reader`.
239+
pub async fn download_versions_to_store(
240+
remote_repo: &RemoteRepository,
241+
hashes: &[String],
242+
version_store: &dyn VersionStore,
243+
) -> Result<u64, OxenError> {
244+
let total_retries = max_retries().try_into().unwrap_or(max_retries() as u64);
245+
let mut num_retries = 0;
246+
247+
while num_retries < total_retries {
248+
match try_download_versions_to_store(remote_repo, hashes, version_store).await {
249+
Ok(val) => return Ok(val),
250+
Err(OxenError::Authentication(val)) => return Err(OxenError::Authentication(val)),
251+
Err(err) => {
252+
num_retries += 1;
253+
let sleep_time = num_retries * num_retries;
254+
log::warn!("Could not download content {err:?} sleeping {sleep_time}");
255+
tokio::time::sleep(std::time::Duration::from_secs(sleep_time)).await;
256+
}
257+
}
258+
}
259+
260+
let err = format!(
261+
"Err: Failed to download {} files after {} retries",
262+
hashes.len(),
263+
total_retries
264+
);
265+
Err(OxenError::basic_str(err))
266+
}
267+
268+
async fn try_download_versions_to_store(
269+
remote_repo: &RemoteRepository,
270+
hashes: &[String],
271+
version_store: &dyn VersionStore,
229272
) -> Result<u64, OxenError> {
230273
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
231274
for hash in hashes.iter() {
232275
let line = format!("{hash}\n");
233-
// log::debug!("download_data_from_version_paths encoding line: {} path: {:?}", line, path);
234276
encoder.write_all(line.as_bytes())?;
235277
}
236278
let body = encoder.finish()?;
237-
log::debug!("download_data_from_version_paths body len: {}", body.len());
279+
log::debug!("download_versions_to_store body len: {}", body.len());
238280

239281
let url = api::endpoint::url_from_repo(remote_repo, "/versions")?;
240282
let client = client::new_for_url(&url)?;
@@ -254,7 +296,6 @@ pub async fn try_download_data_from_version_paths(
254296
let decoder = GzipDecoder::new(buf_reader);
255297
let mut archive = Archive::new(decoder);
256298

257-
let version_store = local_repo.version_store()?;
258299
let mut size: u64 = 0;
259300

260301
// Iterate over archive entries and stream them to version store
@@ -298,8 +339,7 @@ pub async fn try_download_data_from_version_paths(
298339

299340
Ok(size)
300341
} else {
301-
let err =
302-
format!("api::entries::download_data_from_version_paths Err request failed: {url}");
342+
let err = format!("api::versions::download_versions_to_store Err request failed: {url}");
303343
Err(OxenError::basic_str(err))
304344
}
305345
}

oxen-rust/src/lib/src/lfs/config.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ use std::path::Path;
22

33
use serde::{Deserialize, Serialize};
44

5+
use crate::api;
56
use crate::error::OxenError;
7+
use crate::model::RemoteRepository;
68

79
const LFS_CONFIG_FILENAME: &str = "lfs.toml";
810

@@ -25,6 +27,23 @@ impl LfsConfig {
2527
Ok(config)
2628
}
2729

30+
/// Resolve `remote_url` to a [`RemoteRepository`].
31+
///
32+
/// Returns `Ok(None)` when no remote is configured. Returns an error if
33+
/// the URL is set but the repository cannot be found on the server.
34+
pub async fn resolve_remote(&self) -> Result<Option<RemoteRepository>, OxenError> {
35+
let url = match &self.remote_url {
36+
Some(u) if !u.is_empty() => u,
37+
_ => return Ok(None),
38+
};
39+
match api::client::repositories::get_by_url(url).await? {
40+
Some(repo) => Ok(Some(repo)),
41+
None => Err(OxenError::basic_str(format!(
42+
"oxen lfs: remote repository not found at {url}"
43+
))),
44+
}
45+
}
46+
2847
/// Persist to `<oxen_dir>/lfs.toml`.
2948
pub fn save(&self, oxen_dir: &Path) -> Result<(), OxenError> {
3049
let path = oxen_dir.join(LFS_CONFIG_FILENAME);

oxen-rust/src/lib/src/lfs/filter.rs

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use std::path::{Path, PathBuf};
22
use std::process::Command;
3+
use std::time::Duration;
34

5+
use crate::api;
46
use crate::error::OxenError;
57
use crate::lfs::config::LfsConfig;
68
use crate::lfs::pointer::PointerFile;
@@ -32,11 +34,12 @@ pub async fn clean(versions_dir: &Path, content: &[u8]) -> Result<Vec<u8>, OxenE
3234
/// Strategy:
3335
/// 1. Local `.oxen/versions/` store.
3436
/// 2. Origin's `.oxen/versions/` (for local clones — discovered via `git config remote.origin.url`).
35-
/// 3. Fallback: return pointer bytes unchanged with a warning.
37+
/// 3. Configured Oxen remote (with 30 s timeout).
38+
/// 4. Fallback: return pointer bytes unchanged with a warning.
3639
pub async fn smudge(
3740
versions_dir: &Path,
3841
repo_root: &Path,
39-
_lfs_config: &LfsConfig,
42+
lfs_config: &LfsConfig,
4043
pointer_data: &[u8],
4144
) -> Result<Vec<u8>, OxenError> {
4245
// Not a pointer — return data as-is.
@@ -67,7 +70,35 @@ pub async fn smudge(
6770
}
6871
}
6972

70-
// 3. TODO (Phase 3): fetch from Oxen remote with timeout.
73+
// 3. Try the configured Oxen remote with a 30 s timeout.
74+
if lfs_config.remote_url.is_some() {
75+
match tokio::time::timeout(
76+
Duration::from_secs(30),
77+
try_fetch_from_remote(lfs_config, &pointer.oid, &store),
78+
)
79+
.await
80+
{
81+
Ok(Ok(true)) => {
82+
// Successfully downloaded — read from local store.
83+
return store.get_version(&pointer.oid).await;
84+
}
85+
Ok(Ok(false)) => {
86+
log::debug!("oxen lfs smudge: remote configured but fetch returned nothing");
87+
}
88+
Ok(Err(e)) => {
89+
log::warn!(
90+
"oxen lfs smudge: remote fetch failed for {}: {e}",
91+
pointer.oid
92+
);
93+
}
94+
Err(_) => {
95+
log::warn!(
96+
"oxen lfs smudge: remote fetch timed out for {}",
97+
pointer.oid
98+
);
99+
}
100+
}
101+
}
71102

72103
// 4. Fallback — return pointer bytes and warn.
73104
log::warn!(
@@ -77,6 +108,22 @@ pub async fn smudge(
77108
Ok(pointer_data.to_vec())
78109
}
79110

111+
/// Attempt to download a single version from the configured remote.
112+
/// Returns `Ok(true)` if the hash was successfully stored locally.
113+
async fn try_fetch_from_remote(
114+
lfs_config: &LfsConfig,
115+
oid: &str,
116+
store: &LocalVersionStore,
117+
) -> Result<bool, OxenError> {
118+
let remote_repo = match lfs_config.resolve_remote().await? {
119+
Some(r) => r,
120+
None => return Ok(false),
121+
};
122+
let hashes = vec![oid.to_string()];
123+
api::client::versions::download_versions_to_store(&remote_repo, &hashes, store).await?;
124+
store.version_exists(oid).await
125+
}
126+
80127
/// Discover the origin's `.oxen/versions/` directory for local clones.
81128
///
82129
/// Returns `None` if the origin is a remote URL or doesn't have an `.oxen/versions/` dir.
@@ -248,4 +295,25 @@ mod tests {
248295
let path_str = tmp.path().to_string_lossy().to_string();
249296
assert_eq!(as_local_path(&path_str), Some(tmp.path().to_path_buf()));
250297
}
298+
299+
#[tokio::test]
300+
async fn test_smudge_remote_fallback_on_no_server() {
301+
// When remote_url is set to an unreachable server, smudge should
302+
// fall back gracefully to returning the pointer bytes.
303+
let tmp = TempDir::new().unwrap();
304+
let repo_root = tmp.path();
305+
let versions_dir = tmp.path().join("versions");
306+
let config = LfsConfig {
307+
remote_url: Some("http://127.0.0.1:19999/nonexistent/repo".to_string()),
308+
};
309+
310+
let ptr = PointerFile::new("deadbeefdeadbeefdeadbeefdeadbeef", 42);
311+
let pointer_bytes = ptr.encode();
312+
313+
let result = smudge(&versions_dir, repo_root, &config, &pointer_bytes)
314+
.await
315+
.unwrap();
316+
// Should fall back to returning the pointer unchanged.
317+
assert_eq!(result, pointer_bytes);
318+
}
251319
}

oxen-rust/src/lib/src/lfs/filter_process.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,11 @@ pub fn run_filter_process(versions_dir: &Path) -> Result<(), OxenError> {
139139
pkt_line::write_flush(&mut writer)?;
140140
writer.flush()?;
141141

142-
// Phase 2: Git sends capabilities in one flush group.
142+
// Phase 2: Git sends its capabilities (e.g. capability=clean,
143+
// capability=smudge) in one flush group. We read and discard them
144+
// because the protocol requires consuming this flush group before
145+
// we can advertise our own capabilities. We unconditionally
146+
// advertise both clean and smudge regardless of what Git offers.
143147
let _caps = pkt_line::read_text_pairs_until_flush(&mut reader)?;
144148

145149
// Respond with the capabilities we support.

0 commit comments

Comments
 (0)