Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/lib/src/api/client/repositories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ pub async fn get_by_remote(remote: &Remote) -> Result<RemoteRepository, OxenErro
let res = client.get(&url).send().await?;
log::debug!("get_by_remote status: {}", res.status());
if 404 == res.status() {
return Err(OxenError::remote_repo_not_found(&remote.url));
return Err(OxenError::RemoteRepoNotFound(remote.url.as_str().into()));
}

let body = client::parse_json_body(&url, res).await?;
Expand Down
12 changes: 6 additions & 6 deletions crates/lib/src/core/v_latest/commits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,14 +147,14 @@ pub fn latest_commit(repo: &LocalRepository) -> Result<Commit, OxenError> {
latest_commit = Some(commit);
}
}
latest_commit.ok_or(OxenError::no_commits_found())
latest_commit.ok_or_else(|| OxenError::NoCommitsFound)
}

fn head_commit_id(repo: &LocalRepository) -> Result<MerkleHash, OxenError> {
let commit_id = with_ref_manager(repo, |manager| manager.head_commit_id())?;
match commit_id {
Some(commit_id) => Ok(commit_id.parse()?),
None => Err(OxenError::head_not_found()),
None => Err(OxenError::HeadNotFound),
}
}

Expand Down Expand Up @@ -257,7 +257,7 @@ pub fn create_empty_commit(
) -> Result<Commit, OxenError> {
let branch_name = branch_name.as_ref();
let Some(existing_commit) = repositories::revisions::get(repo, branch_name)? else {
return Err(OxenError::revision_not_found(branch_name.into()));
return Err(OxenError::RevisionNotFound(branch_name.into()));
};
let existing_commit_id = existing_commit.id.parse()?;
let existing_node =
Expand Down Expand Up @@ -677,9 +677,9 @@ pub fn list_from_paginated_impl(
let base = split[0];
let head = split[1];
let base_commit = repositories::commits::get_by_id(repo, base)?
.ok_or(OxenError::revision_not_found(base.into()))?;
.ok_or(OxenError::RevisionNotFound(base.into()))?;
let head_commit = repositories::commits::get_by_id(repo, head)?
.ok_or(OxenError::revision_not_found(head.into()))?;
.ok_or(OxenError::RevisionNotFound(head.into()))?;

let (commits, total_count) =
list_recursive_paginated(repo, head_commit, skip, limit, Some(&base_commit), None)?;
Expand Down Expand Up @@ -791,7 +791,7 @@ pub fn count_from(
let revision = revision.as_ref();

let commit = repositories::revisions::get(repo, revision)?
.ok_or_else(|| OxenError::revision_not_found(revision.into()))?;
.ok_or_else(|| OxenError::RevisionNotFound(revision.into()))?;

let db = open_commit_count_db(repo)?;

Expand Down
4 changes: 2 additions & 2 deletions crates/lib/src/core/v_latest/entries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ pub fn list_directory_with_depth(
let commit = parsed_resource
.commit
.clone()
.ok_or(OxenError::revision_not_found(revision.into()))?;
.ok_or(OxenError::RevisionNotFound(revision.into()))?;

log::debug!("list_directory commit {commit}");

Expand Down Expand Up @@ -537,7 +537,7 @@ pub fn list_for_commit(

pub fn update_metadata(repo: &LocalRepository, revision: impl AsRef<str>) -> Result<(), OxenError> {
let commit = repositories::revisions::get(repo, revision.as_ref())?
.ok_or_else(|| OxenError::revision_not_found(revision.as_ref().to_string().into()))?;
.ok_or_else(|| OxenError::RevisionNotFound(revision.as_ref().to_string().into()))?;
let tree: CommitMerkleTree = CommitMerkleTree::from_commit(repo, &commit)?;
let mut node = tree.root;

Expand Down
6 changes: 3 additions & 3 deletions crates/lib/src/core/v_latest/pull.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ pub async fn pull_remote_branch(
let remote_branch = fetch::fetch_remote_branch(repo, &remote_repo, &fetch_opts).await?;

let new_head_commit = repositories::revisions::get(repo, &remote_branch.commit_id)?.ok_or(
OxenError::revision_not_found(remote_branch.commit_id.to_owned().into()),
OxenError::RevisionNotFound(remote_branch.commit_id.to_owned().into()),
)?;

match previous_head_commit {
Expand All @@ -72,8 +72,8 @@ pub async fn pull_remote_branch(
}
Ok(None) => {
// Merge conflict, keep the previous commit
return Err(OxenError::merge_conflict(
"There was a merge conflict, please resolve it before pulling",
return Err(OxenError::UpstreamMergeConflict(
"There was a merge conflict, please resolve it before pulling.".into(),
));
}
Err(e) => return Err(e),
Expand Down
4 changes: 2 additions & 2 deletions crates/lib/src/core/v_latest/push.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ async fn push_local_branch_to_remote_repo(
) -> Result<(), OxenError> {
// Get the commit from the branch
let Some(commit) = repositories::commits::get_by_id(repo, &local_branch.commit_id)? else {
return Err(OxenError::revision_not_found(
return Err(OxenError::RevisionNotFound(
local_branch.commit_id.clone().into(),
));
};
Expand Down Expand Up @@ -136,7 +136,7 @@ async fn push_to_existing_branch(

let latest_remote_commit =
repositories::commits::get_by_id(repo, &remote_branch.commit_id)?.ok_or_else(
|| OxenError::revision_not_found(remote_branch.commit_id.clone().into()),
|| OxenError::RevisionNotFound(remote_branch.commit_id.clone().into()),
)?;

let mut commits =
Expand Down
26 changes: 12 additions & 14 deletions crates/lib/src/core/v_latest/workspaces/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ pub async fn commit(

let conflicts = list_conflicts(workspace, &dir_entries, &branch)?;
if !conflicts.is_empty() {
return Err(OxenError::workspace_behind(workspace));
return Err(OxenError::WorkspaceBehind(Box::new(workspace.clone())));
}

let dir_entries = export_tabular_data_frames(workspace, dir_entries).await?;
Expand Down Expand Up @@ -110,16 +110,14 @@ pub fn mergeability(
let branch_name = branch_name.as_ref();
let Some(branch) = repositories::branches::get_by_name(&workspace.base_repo, branch_name)?
else {
return Err(OxenError::revision_not_found(
branch_name.to_string().into(),
));
return Err(OxenError::BranchNotFound(branch_name.into()));
Comment on lines 111 to +113
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

BranchNotFound now renders only the raw branch name.

crates/lib/src/error.rs formats OxenError::BranchNotFound as "{0}", so this path currently returns just main / feature/foo instead of a full not-found message. Please keep using the local-branch helper here or format the message before constructing the variant.

💡 Suggested fix
-        return Err(OxenError::BranchNotFound(branch_name.into()));
+        return Err(OxenError::local_branch_not_found(branch_name));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let Some(branch) = repositories::branches::get_by_name(&workspace.base_repo, branch_name)?
else {
return Err(OxenError::revision_not_found(
branch_name.to_string().into(),
));
return Err(OxenError::BranchNotFound(branch_name.into()));
let Some(branch) = repositories::branches::get_by_name(&workspace.base_repo, branch_name)?
else {
return Err(OxenError::local_branch_not_found(branch_name));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/lib/src/core/v_latest/workspaces/commit.rs` around lines 111 - 113,
The BranchNotFound variant is being constructed with the raw branch_name, which
renders just "main" instead of a full not-found message; update the error
construction in the branch lookup in commit.rs (the let Some(branch) =
repositories::branches::get_by_name(&workspace.base_repo, branch_name)? else {
... } block) to pass a formatted/localized branch string (use the existing
local_branch helper or otherwise format the branch name into the full message)
into OxenError::BranchNotFound rather than passing branch_name.into() so the
error message prints the full human-readable text.

};

let base = &workspace.commit;
let Some(head) = repositories::commits::get_by_id(&workspace.base_repo, &branch.commit_id)?
else {
return Err(OxenError::revision_not_found(
branch.commit_id.clone().into(),
return Err(OxenError::RevisionNotFound(
branch.commit_id.as_str().into(),
));
};

Expand Down Expand Up @@ -174,8 +172,8 @@ fn list_conflicts(
let Some(branch_commit) =
repositories::commits::get_by_id(&workspace.base_repo, &branch.commit_id)?
else {
return Err(OxenError::revision_not_found(
branch.commit_id.clone().into(),
return Err(OxenError::RevisionNotFound(
branch.commit_id.as_str().into(),
));
};
log::debug!(
Expand All @@ -195,22 +193,22 @@ fn list_conflicts(
let Some(branch_commit) =
repositories::commits::get_by_id(&workspace.base_repo, &branch.commit_id)?
else {
return Err(OxenError::revision_not_found(
branch.commit_id.clone().into(),
return Err(OxenError::RevisionNotFound(
branch.commit_id.as_str().into(),
));
};
let Some(branch_tree) =
repositories::tree::get_root_with_children(&workspace.base_repo, &branch_commit)?
else {
return Err(OxenError::revision_not_found(
branch.commit_id.clone().into(),
return Err(OxenError::RevisionNotFound(
branch.commit_id.as_str().into(),
));
};
let Some(workspace_tree) =
repositories::tree::get_root_with_children(&workspace.base_repo, workspace_commit)?
else {
return Err(OxenError::revision_not_found(
workspace.commit.id.clone().into(),
return Err(OxenError::RevisionNotFound(
workspace.commit.id.as_str().into(),
));
};

Expand Down
6 changes: 3 additions & 3 deletions crates/lib/src/core/v_latest/workspaces/data_frames.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ pub fn is_queryable_data_frame_indexed(
match get_queryable_data_frame_workspace(repo, path, commit) {
Ok(_workspace) => Ok(true),
Err(e) => match e {
OxenError::QueryableWorkspaceNotFound() => Ok(false),
OxenError::QueryableWorkspaceNotFound => Ok(false),
_ => Err(e),
},
}
Expand All @@ -47,7 +47,7 @@ pub fn is_queryable_data_frame_indexed_from_file_node(
{
Ok(_workspace) => Ok(true),
Err(e) => match e {
OxenError::QueryableWorkspaceNotFound() => Ok(false),
OxenError::QueryableWorkspaceNotFound => Ok(false),
_ => Err(e),
},
}
Expand Down Expand Up @@ -78,7 +78,7 @@ pub fn get_queryable_data_frame_workspace_from_file_node(
}
}

Err(OxenError::QueryableWorkspaceNotFound())
Err(OxenError::QueryableWorkspaceNotFound)
}

pub fn get_queryable_data_frame_workspace(
Expand Down
2 changes: 1 addition & 1 deletion crates/lib/src/core/v_latest/workspaces/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ pub fn diff(workspace: &Workspace, path: impl AsRef<Path>) -> Result<DiffResult,
);

let file_node = repositories::tree::get_file_by_path(repo, commit, path)?
.ok_or(OxenError::entry_does_not_exist(path))?;
.ok_or_else(|| OxenError::entry_does_not_exist(path))?;

log::debug!("diff_workspace_df got file_node {file_node}");

Expand Down
48 changes: 33 additions & 15 deletions crates/lib/src/core/v_latest/workspaces/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,26 +270,40 @@ fn is_cgn_or_reserved_v4(octets: [u8; 4]) -> bool {
false
}

#[derive(Debug, thiserror::Error)]
enum InvalidUrlError {
#[error("URL has no host")]
NoHost,

#[error("DNS resolution failed for {host}: {source}")]
DnsResolutionFailed {
host: String,
source: std::io::Error,
},

#[error("URL resolves to a private/reserved IP address: {0}")]
PrivateIp(std::net::IpAddr),
}

/// Resolves a URL's hostname via DNS and rejects it if any resolved address is
/// private/reserved. This prevents SSRF attacks where a user-supplied URL could reach
/// internal services (e.g. cloud metadata at 169.254.169.254, internal APIs, etc.).
async fn validate_url_target(url: &Url) -> Result<(), OxenError> {
let host = url
.host_str()
.ok_or_else(|| OxenError::file_import_error("URL has no host"))?;
async fn validate_url_target(url: &Url) -> Result<(), InvalidUrlError> {
let host = url.host_str().ok_or(InvalidUrlError::NoHost)?;
let port = url.port_or_known_default().unwrap_or(443);
let addr = format!("{host}:{port}");

let resolved = tokio::net::lookup_host(&addr).await.map_err(|e| {
OxenError::file_import_error(format!("DNS resolution failed for {host}: {e}"))
})?;
let resolved =
tokio::net::lookup_host(&addr)
.await
.map_err(|e| InvalidUrlError::DnsResolutionFailed {
host: host.to_string(),
source: e,
})?;
Comment on lines +291 to +302
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

In reqwest 0.13.x, does Client::get(url).send()perform DNS resolution independently of a priortokio::net::lookup_host, and is tokio::net::lookup_host((host, port))the correct way to resolve IPv6 literals returned byurl::Url::host_str()?

💡 Result:

In reqwest 0.13.x, Client::get(url).send performs DNS resolution independently of any prior tokio::net::lookup_host call. Reqwest uses its own configurable DNS resolver (via ClientBuilder::dns_resolver), which resolves the host from the URL's http::Uri during connection setup. A prior lookup_host does not affect or share state with reqwest's internal resolution process. tokio::net::lookup_host((host, port)) is the correct way to resolve an IPv6 literal returned by url::Url::host_str. The host_str returns the raw host string (e.g., "[::1]" for IPv6 literals). lookup_host uses std::net::ToSocketAddrs, which parses IP literals (IPv4/IPv6) directly without DNS lookup, combining them with the provided port to yield SocketAddrs. For domain names, it performs DNS resolution. This matches reqwest's expected SocketAddr format from its resolver.

Citations:


🏁 Script executed:

# First, check the file structure and see lines around the mentioned locations
wc -l crates/lib/src/core/v_latest/workspaces/files.rs

Repository: Oxen-AI/Oxen

Length of output: 109


🏁 Script executed:

# Check the validate_url_target function (lines 291-302)
sed -n '285,310p' crates/lib/src/core/v_latest/workspaces/files.rs

Repository: Oxen-AI/Oxen

Length of output: 984


🏁 Script executed:

# Check lines 370-372 and 498-500 for similar patterns
sed -n '365,375p' crates/lib/src/core/v_latest/workspaces/files.rs
echo "---"
sed -n '493,505p' crates/lib/src/core/v_latest/workspaces/files.rs

Repository: Oxen-AI/Oxen

Length of output: 891


🏁 Script executed:

# Find how the validated result from validate_url_target is used (if at all)
rg "validate_url_target" -A 10 -B 2 crates/lib/src/core/v_latest/workspaces/files.rs

Repository: Oxen-AI/Oxen

Length of output: 1457


🏁 Script executed:

# Check if reqwest::Client is used to send requests with the original URL
rg "reqwest.*Client|\.get\(" crates/lib/src/core/v_latest/workspaces/files.rs

Repository: Oxen-AI/Oxen

Length of output: 234


🏁 Script executed:

# Let me check the URL library documentation and test how host_str() works for IPv6
# Search for url crate usage in the file
rg "use.*url|from_str.*Url" crates/lib/src/core/v_latest/workspaces/files.rs | head -20

Repository: Oxen-AI/Oxen

Length of output: 69


🏁 Script executed:

# Check Cargo.toml for url crate version
fd Cargo.toml crates/lib | xargs grep -A 10 "^\[dependencies\]" | head -30

Repository: Oxen-AI/Oxen

Length of output: 417


🏁 Script executed:

# Let's search for how IPv6 URLs would be handled - check if there are any tests
fd "test.*files\|files.*test" --type f | xargs grep -l "validate_url_target\|ipv6\|::" 2>/dev/null | head -5

Repository: Oxen-AI/Oxen

Length of output: 38


🏁 Script executed:

# Look for actual uses of fetch_file and how URLs are constructed to understand the full flow
sed -n '320,350p' crates/lib/src/core/v_latest/workspaces/files.rs

Repository: Oxen-AI/Oxen

Length of output: 946


🏁 Script executed:

# Check if there's a return value or stored SocketAddrs from validate_url_target
ast-grep --pattern 'fn validate_url_target($_) -> $_ {
  $$$
  Ok($_)
}'

Repository: Oxen-AI/Oxen

Length of output: 38


🏁 Script executed:

# Let's check what the actual fetch function does and how it uses the URL
sed -n '380,420p' crates/lib/src/core/v_latest/workspaces/files.rs

Repository: Oxen-AI/Oxen

Length of output: 1136


🏁 Script executed:

# Let's examine the fetch_file function to confirm the DNS-rebinding issue
sed -n '251,290p' crates/lib/src/core/v_latest/workspaces/files.rs

Repository: Oxen-AI/Oxen

Length of output: 1263


🏁 Script executed:

# Check if resolved SocketAddrs are ever used after validation
rg "resolved|SocketAddr" crates/lib/src/core/v_latest/workspaces/files.rs

Repository: Oxen-AI/Oxen

Length of output: 188


🏁 Script executed:

# Let's check the fetch_file signature and how it uses current_url
sed -n '330,360p' crates/lib/src/core/v_latest/workspaces/files.rs

Repository: Oxen-AI/Oxen

Length of output: 906


🏁 Script executed:

# Confirm that validate_url_target result is not reused - check the full context around the call
sed -n '365,385p' crates/lib/src/core/v_latest/workspaces/files.rs

Repository: Oxen-AI/Oxen

Length of output: 598


🏁 Script executed:

# Let's verify how the URL and SocketAddrs would flow through - check if resolved is ever stored/returned
sed -n '291,315p' crates/lib/src/core/v_latest/workspaces/files.rs

Repository: Oxen-AI/Oxen

Length of output: 857


🏁 Script executed:

# Let me double-check the fetch_file implementation to see how it uses the URL
sed -n '450,480p' crates/lib/src/core/v_latest/workspaces/files.rs

Repository: Oxen-AI/Oxen

Length of output: 1103


🏁 Script executed:

# Look for the complete fetch_file function signature
rg "async fn fetch_file\|client\.get" -A 2 crates/lib/src/core/v_latest/workspaces/files.rs | head -20

Repository: Oxen-AI/Oxen

Length of output: 38


validate_url_target() discards the DNS validation result before the request is sent, leaving a DNS-rebinding vulnerability.

The function validates that a hostname resolves to a non-private IP, but returns only Ok(()) without preserving the validated SocketAddrs. Later, fetch_file() calls client.get(current_url.as_str()), which causes reqwest to perform DNS resolution independently. An attacker controlling DNS can change the record between the two resolutions to point to a private IP address, bypassing the validation. Return the validated SocketAddrs from validate_url_target() and pass them to the request path instead of re-resolving by hostname.

Also applies to: 370-372, 498-500

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/lib/src/core/v_latest/workspaces/files.rs` around lines 291 - 302,
validate_url_target currently performs DNS resolution and checks IPs but
discards the resolved SocketAddr results, letting fetch_file call
client.get(hostname) which triggers a fresh DNS lookup and opens a DNS-rebinding
window; change validate_url_target to return the validated Vec<SocketAddr> (or
similar) instead of just Ok(()) and update callers (e.g., fetch_file) to pass
those SocketAddrs into the request so reqwest does not re-resolve the hostname
(use the resolved SocketAddr for connecting or configure the request client to
use the provided IPs), and apply the same change pattern to the other
occurrences noted (around the sections referenced at 370-372 and 498-500).


for socket_addr in resolved {
if is_private_ip(&socket_addr.ip()) {
return Err(OxenError::file_import_error(format!(
"URL resolves to a private/reserved IP address: {}",
socket_addr.ip()
)));
return Err(InvalidUrlError::PrivateIp(socket_addr.ip()));
}
}

Expand Down Expand Up @@ -353,7 +367,9 @@ pub async fn import(
));
}

validate_url_target(&parsed_url).await?;
validate_url_target(&parsed_url)
.await
.map_err(|e| OxenError::ImportFileError(format!("{e}").into()))?;

let auth_header_value = HeaderValue::from_str(auth)
.map_err(|_e| OxenError::file_import_error(format!("Invalid header auth value {auth}")))?;
Expand Down Expand Up @@ -407,12 +423,12 @@ pub async fn upload_zip(
log::debug!("workspace::commit ✅ success! commit {commit:?}");
Ok(commit)
}
Err(OxenError::WorkspaceBehind(workspace)) => {
workspace_behind_error @ Err(OxenError::WorkspaceBehind(_)) => {
log::error!(
"unable to commit branch {:?}. Workspace behind",
branch.name
);
Err(OxenError::WorkspaceBehind(workspace))
workspace_behind_error
Comment on lines +426 to +431
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The zip-import path still needs the WorkspaceBehind HTTP translation.

This branch re-propagates the raw OxenError, but crates/server/src/controllers/import.rs still calls repositories::workspaces::files::upload_zip(...).await? directly. Unlike crates/server/src/controllers/workspaces.rs:416-420, that path never builds the structured OxenHttpError::WorkspaceBehind(...) response, so stale zip imports will miss the conflict payload the regular workspace endpoints emit.

🔎 Quick verification
#!/bin/bash
rg -n --type=rust "workspaces::files::upload_zip|WorkspaceBehind" crates/server/src/controllers -C3
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/lib/src/core/v_latest/workspaces/files.rs` around lines 426 - 431, The
match arm handling workspace_behind_error in upload_zip is re-propagating the
raw OxenError::WorkspaceBehind and should instead translate it to the structured
HTTP error used elsewhere; update the workspace_behind_error @
Err(OxenError::WorkspaceBehind(_)) arm in the upload_zip function to construct
and return the OxenHttpError::WorkspaceBehind payload (matching the translation
used in controllers/workspaces.rs) so that callers like controllers/import.rs
receive the proper WorkspaceBehind HTTP response with the conflict payload.

}
Err(err) => {
log::error!("unable to commit branch {:?}. Err: {}", branch.name, err);
Expand Down Expand Up @@ -479,7 +495,9 @@ async fn fetch_file(
"Redirect to non-HTTP(S) URL is not allowed",
));
}
validate_url_target(&next_url).await?;
validate_url_target(&next_url)
.await
.map_err(|e| OxenError::ImportFileError(format!("{e}").into()))?;

current_url = next_url;
continue;
Expand Down
2 changes: 1 addition & 1 deletion crates/lib/src/core/v_old/v0_19_0/pull.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ pub async fn pull_remote_branch(
fetch::fetch_remote_branch(repo, &remote_repo, fetch_opts).await?;

let new_head_commit = repositories::revisions::get(repo, branch)?
.ok_or(OxenError::revision_not_found(branch.to_owned().into()))?;
.ok_or(OxenError::RevisionNotFound(branch.to_owned().into()))?;

// Merge if there are changes
if let Some(previous_head_commit) = &previous_head_commit {
Expand Down
Loading
Loading