#[cfg(feature = "client")]
use std::net::SocketAddr;
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
use anyhow::{Context, Result};
#[cfg(feature = "client")]
use heddle_client::grpc_hosted::{HostedAuthMode, PullMaterialization};
use objects::{
fs_atomic::write_file_atomic,
object::{ChangeId, ThreadName, Tree},
store::ObjectStore,
};
use refs::Head;
use repo::{Repository, RepositoryCapability};
use serde::Serialize;
use sley::{
GitConfig, Repository as SleyRepository,
plumbing::sley_config::{
ConfigIncludeContext, ConfigOriginKind, ConfigScope, ConfigStack, ConfigStackEntry,
},
};
use super::super::{
action_line::print_next,
advice::RecoveryAdvice,
git_overlay_health::{
RepositoryVerificationState, build_plain_git_verification_probe,
build_repository_verification_state,
},
worktree_safety::ensure_worktree_clean,
};
#[cfg(feature = "client")]
use crate::client::HostedGrpcClient;
use crate::{
bridge::{GitBridge, git_core::GitPullOutcome},
cli::{Cli, RemoteCommands, should_output_json, style},
client::LocalSync,
config::UserConfig,
remote::{Remote, RemoteConfig, RemoteError, RemoteTarget, resolve_remote_with_key},
};
#[derive(Serialize)]
struct RemoteListOutput {
output_kind: &'static str,
remotes: Vec<RemoteInfoOutput>,
}
#[derive(Serialize)]
struct RemoteInfoOutput {
#[serde(skip_serializing_if = "Option::is_none")]
output_kind: Option<&'static str>,
name: String,
url: String,
source: String,
is_default: bool,
}
#[derive(Serialize)]
struct RemoteMutationOutput {
output_kind: &'static str,
status: &'static str,
action: &'static str,
name: String,
url: Option<String>,
default: Option<String>,
message: String,
#[allow(dead_code)]
#[serde(skip_serializing)]
#[serde(rename = "verification")]
trust: RepositoryVerificationState,
}
#[derive(Serialize)]
struct PullOutput {
output_kind: &'static str,
action: &'static str,
status: &'static str,
success: bool,
pulled: bool,
changed: bool,
transport: &'static str,
remote: String,
#[serde(skip_serializing_if = "Option::is_none")]
branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
old_git_head: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
new_git_head: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
old_state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
new_state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
states_created: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
commits_seen: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
commits_seen_scope: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
materialized_checkout: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
changed_path_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
changed_paths: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
thread: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
objects: Option<usize>,
#[allow(dead_code)]
#[serde(skip_serializing)]
#[serde(rename = "verification")]
trust: RepositoryVerificationState,
}
struct GitOverlayPullOutputInput {
remote: String,
branch: Option<String>,
old_git_head: Option<String>,
new_git_head: Option<String>,
old_state: Option<ChangeId>,
new_state: Option<ChangeId>,
changed_paths: Vec<String>,
outcome: GitPullOutcome,
trust: RepositoryVerificationState,
}
fn git_overlay_pull_output(input: GitOverlayPullOutputInput) -> PullOutput {
PullOutput {
output_kind: "pull",
action: "pull",
status: pull_status(input.outcome.changed),
success: true,
pulled: input.outcome.changed,
changed: input.outcome.changed,
transport: "git",
remote: input.remote,
branch: input.branch,
old_git_head: input.old_git_head,
new_git_head: input.new_git_head,
old_state: input.old_state.map(|state| state.to_string()),
new_state: input.new_state.map(|state| state.to_string()),
states_created: Some(input.outcome.states_created),
commits_seen: Some(input.outcome.commits_seen),
commits_seen_scope: Some("branches_and_heddle_notes"),
materialized_checkout: Some(input.outcome.materialized_checkout),
changed_path_count: Some(input.changed_paths.len()),
changed_paths: Some(input.changed_paths),
thread: None,
state: None,
objects: None,
trust: input.trust,
}
}
fn heddle_pull_output(
changed: bool,
remote: String,
thread: String,
state: Option<String>,
objects: Option<usize>,
trust: RepositoryVerificationState,
) -> PullOutput {
PullOutput {
output_kind: "pull",
action: "pull",
status: pull_status(changed),
success: true,
pulled: changed,
changed,
transport: "heddle",
remote,
branch: None,
old_git_head: None,
new_git_head: None,
old_state: None,
new_state: None,
states_created: None,
commits_seen: None,
commits_seen_scope: None,
materialized_checkout: None,
changed_path_count: None,
changed_paths: None,
thread: Some(thread),
state,
objects,
trust,
}
}
fn pull_status(changed: bool) -> &'static str {
if changed { "updated" } else { "up_to_date" }
}
pub async fn cmd_pull(
cli: &Cli,
remote: Option<String>,
thread: Option<String>,
local_thread: Option<String>,
lazy: bool,
) -> Result<()> {
let repo = cli.open_repo()?;
if remote.is_none() && resolved_default_remote_name(&repo)?.is_none() {
return Err(anyhow::anyhow!(RecoveryAdvice::remote_not_configured(
"pull"
)));
}
if repo.capability() == RepositoryCapability::GitOverlay && !repo.hosted_enabled() {
ensure_worktree_clean(&repo, "pull")?;
let remote_name = resolve_default_remote_name(&repo, remote.as_deref())?;
let branch = repo.git_overlay_current_branch()?;
let old_git_head = git_checkout_head_oid(repo.root());
let old_state = repo.head()?;
let mut bridge = GitBridge::new(&repo);
let outcome = bridge.pull(&remote_name)?;
let new_git_head = git_checkout_head_oid(repo.root());
let new_state = repo.head()?;
let changed_paths =
changed_paths_between_states(&repo, old_state.as_ref(), new_state.as_ref())?;
let verification = build_repository_verification_state(&repo);
if should_output_json(cli, Some(repo.config())) {
let output = git_overlay_pull_output(GitOverlayPullOutputInput {
remote: remote_name,
branch,
old_git_head,
new_git_head,
old_state,
new_state,
changed_paths,
outcome,
trust: verification,
});
crate::cli::render::write_json_stdout(&output)?;
} else {
if outcome.changed {
println!(
"{} pulled from {}",
style::ok_marker(),
style::bold(&remote_name)
);
} else {
println!(
"{} already up to date with {}; repository verification checked below",
style::ok_marker(),
style::bold(&remote_name)
);
}
if let Some(branch) = &branch {
if outcome.changed {
println!("Branch: {}", style::bold(branch));
} else if let Some(head) = &new_git_head {
println!("Branch: {} at {}", style::bold(branch), short_oid(head));
}
}
match (&old_git_head, &new_git_head) {
(Some(old), Some(new)) if old != new => {
println!("Git: {} -> {}", short_oid(old), short_oid(new));
}
(Some(head), Some(_)) if outcome.changed => {
println!("Git: {}", short_oid(head));
}
_ => {}
}
println!(
"Imported: {}",
style::count(outcome.states_created, "new state")
);
println!(
"Scanned: {} across branches + refs/notes/heddle",
style::count(outcome.commits_seen, "Git commit object")
);
if outcome.materialized_checkout {
println!("Worktree: materialized checkout");
}
if outcome.changed {
println!("Changed paths: {}", changed_paths.len());
for path in changed_paths.iter().take(8) {
println!(" - {path}");
}
if changed_paths.len() > 8 {
println!(" - ... {} more", changed_paths.len() - 8);
}
}
if !verification.verified {
println!("Workspace: {}", style::warn(&verification.status));
if !verification.recommended_action.is_empty() {
print_next(&verification.recommended_action);
}
} else {
println!("Workspace: verified");
}
}
return Ok(());
}
super::preflight_native_remote_transport(&repo, remote.as_deref(), "pull")?;
let user_config = UserConfig::load_default()?;
#[cfg(not(feature = "client"))]
let token = user_config.remote_token()?;
#[cfg(feature = "client")]
let (target, server_key) =
resolve_remote_with_key(&repo, remote.as_deref()).map_err(anyhow::Error::msg)?;
#[cfg(not(feature = "client"))]
let (target, _server_key) =
resolve_remote_with_key(&repo, remote.as_deref()).map_err(anyhow::Error::msg)?;
let remote_thread = thread.unwrap_or_else(|| "main".to_string());
let local_thread_name = local_thread.as_deref();
let should_materialize = match repo.head_ref()? {
Head::Attached { thread } => local_thread_name.is_none_or(|local| thread == local),
Head::Detached { .. } => local_thread_name.is_none(),
};
if should_materialize {
ensure_worktree_clean(&repo, "pull")?;
}
match target {
RemoteTarget::Local(path) => {
pull_local(&repo, &path, &remote_thread, local_thread_name, cli, lazy).await?;
}
RemoteTarget::Network { addr, repo_path } => {
#[cfg(feature = "client")]
pull_network(
&repo,
PullNetworkOptions {
addr,
repo_path: repo_path.as_deref(),
user_config: &user_config,
server_key,
remote_thread: &remote_thread,
local_thread: local_thread_name,
lazy,
cli,
},
)
.await?;
#[cfg(not(feature = "client"))]
let _ = (addr, repo_path, token);
#[cfg(not(feature = "client"))]
anyhow::bail!(RecoveryAdvice::network_feature_unavailable("pull"));
}
}
Ok(())
}
async fn pull_local(
repo: &Repository,
source_path: &std::path::Path,
remote_thread: &str,
local_thread: Option<&str>,
cli: &Cli,
lazy: bool,
) -> Result<()> {
if lazy {
return Err(anyhow::anyhow!(
RecoveryAdvice::local_lazy_pull_unsupported(source_path)
));
}
if !should_output_json(cli, Some(repo.config())) {
println!(
"{} pulling from {}",
style::working_marker(),
style::dim(&format!("file://{}", source_path.display()))
);
}
let source = LocalSync::open(source_path)?;
let state_id = source
.source()
.refs()
.get_thread(&ThreadName::new(remote_thread))?
.context(format!("Thread {} not found in source", remote_thread))?;
let objects_copied = source.fetch_state(repo, &state_id)?;
let track_to_update = local_thread.unwrap_or(remote_thread);
let track_tn = ThreadName::new(track_to_update);
let pre_target = repo.refs().get_thread(&track_tn)?;
let changed = pre_target.as_ref() != Some(&state_id) || objects_copied > 0;
let head_ref = repo.head_ref()?;
let should_materialize = match &head_ref {
Head::Attached { thread } => thread == track_to_update,
Head::Detached { .. } => local_thread.is_none(),
};
if should_materialize {
match (&head_ref, pre_target) {
(Head::Attached { .. }, Some(_)) => {
super::super::ff_record::record_ff_advance(repo, remote_thread, &state_id)?;
}
(Head::Attached { .. }, None) => {
repo.fast_forward_attached_from_materialized_state(&state_id, None)?;
}
(Head::Detached { .. }, _) => {
repo.goto(&state_id)?;
repo.refs().set_thread(&track_tn, &state_id)?;
}
}
} else {
repo.refs().set_thread(&track_tn, &state_id)?;
}
if should_output_json(cli, Some(repo.config())) {
let output = heddle_pull_output(
changed,
source_path.display().to_string(),
track_to_update.to_string(),
Some(state_id.to_string()),
Some(objects_copied),
build_repository_verification_state(repo),
);
crate::cli::render::write_json_stdout(&output)?;
} else {
println!(
"{} pulled {} from {} ({})",
style::ok_marker(),
style::change_id(&state_id.short().to_string()),
style::bold(remote_thread),
style::count(objects_copied, "object")
);
}
Ok(())
}
fn git_checkout_head_oid(root: &Path) -> Option<String> {
let git = SleyRepository::discover(root).ok()?;
git.head().ok()?.oid.map(|oid| oid.to_string())
}
fn short_oid(oid: &str) -> String {
oid.chars().take(12).collect()
}
fn changed_paths_between_states(
repo: &Repository,
old_state: Option<&ChangeId>,
new_state: Option<&ChangeId>,
) -> Result<Vec<String>> {
if old_state == new_state {
return Ok(Vec::new());
}
let Some(new_state) = new_state else {
return Ok(Vec::new());
};
let new_state = repo
.store()
.get_state(new_state)?
.context("new pulled state was not found in Heddle storage")?;
let old_tree = match old_state {
Some(old_state) => repo
.store()
.get_state(old_state)?
.map(|state| state.tree)
.unwrap_or_else(|| Tree::new().hash()),
None => Tree::new().hash(),
};
let mut paths = repo
.diff_trees(&old_tree, &new_state.tree)?
.iter()
.map(|change| change.path.clone())
.collect::<Vec<_>>();
paths.sort();
paths.dedup();
Ok(paths)
}
#[cfg(feature = "client")]
async fn pull_network(repo: &Repository, options: PullNetworkOptions<'_>) -> Result<()> {
let repo_path = options
.repo_path
.context("network remotes must include a hosted repository path")?;
let mut client = HostedGrpcClient::open_session(
options.addr,
options.user_config,
options.server_key,
HostedAuthMode::CredentialFallback,
)
.await?;
if !should_output_json(options.cli, Some(repo.config())) {
println!(
"{} connected to {}",
style::ok_marker(),
style::dim(&options.addr.to_string())
);
}
let result = client
.pull_with_depth_and_materialization(
repo,
repo_path,
options.remote_thread,
options.local_thread,
None,
if options.lazy {
PullMaterialization::Lazy
} else {
PullMaterialization::Full
},
)
.await?;
if result.success {
let changed = result.final_state.is_some();
if should_output_json(options.cli, Some(repo.config())) {
let output = heddle_pull_output(
changed,
options.remote_thread.to_string(),
options
.local_thread
.unwrap_or(options.remote_thread)
.to_string(),
result.final_state.map(|state| state.to_string()),
None,
build_repository_verification_state(repo),
);
crate::cli::render::write_json_stdout(&output)?;
} else {
println!(
"{} pulled from {}",
style::ok_marker(),
style::bold(options.remote_thread)
);
if let Some(final_state) = result.final_state {
println!(
"{}",
style::field("state", &style::change_id(&final_state.to_string()))
);
}
}
} else {
let err = result.error.unwrap_or_else(|| "Unknown error".to_string());
return Err(anyhow::anyhow!(RecoveryAdvice::remote_pull_failed(
options.remote_thread,
options.local_thread,
&err,
)));
}
Ok(())
}
pub fn cmd_remote(cli: &Cli, command: RemoteCommands) -> Result<()> {
let cwd = std::env::current_dir()?;
let start = cli.repo.as_ref().unwrap_or(&cwd);
match &command {
RemoteCommands::List => {
if let Some(probe) = build_plain_git_verification_probe(start)? {
let items = plain_git_remote_items(&probe.root);
let default = default_remote_from_items(&items);
let output = RemoteListOutput {
output_kind: "remote_list",
remotes: items
.into_iter()
.map(|(name, url)| {
let is_default = default.as_deref() == Some(name.as_str());
RemoteInfoOutput {
output_kind: None,
name,
url,
source: "git".to_string(),
is_default,
}
})
.collect(),
};
render_remote_list(&output, should_output_json(cli, None))?;
return Ok(());
}
}
RemoteCommands::Show { name } => {
if let Some(probe) = build_plain_git_verification_probe(start)? {
let items = plain_git_remote_items(&probe.root);
let default = default_remote_from_items(&items);
let url = items
.get(name)
.cloned()
.ok_or_else(|| RecoveryAdvice::remote_not_found(name))?;
let output = RemoteInfoOutput {
output_kind: Some("remote_show"),
name: name.clone(),
url,
source: "git".to_string(),
is_default: default.as_deref() == Some(name.as_str()),
};
render_remote_info(&output, should_output_json(cli, None))?;
return Ok(());
}
}
RemoteCommands::Add { .. }
| RemoteCommands::Remove { .. }
| RemoteCommands::SetDefault { .. } => {}
}
let repo = Repository::open(start)?;
match command {
RemoteCommands::List => {
let items = merged_remote_items(&repo)?;
let default = resolved_default_remote_name(&repo)?;
let output = RemoteListOutput {
output_kind: "remote_list",
remotes: items
.into_iter()
.map(|(name, (url, source))| {
let is_default = default.as_deref() == Some(name.as_str());
RemoteInfoOutput {
output_kind: None,
name,
url,
source,
is_default,
}
})
.collect(),
};
render_remote_list(&output, should_output_json(cli, Some(repo.config())))?;
Ok(())
}
RemoteCommands::Add { name, url } => {
super::preflight_native_remote_transport(&repo, Some(&url), "remote add")?;
let git_overlay_default_before = (repo.capability()
== RepositoryCapability::GitOverlay)
.then(|| git_overlay_default_remote_name(&repo))
.flatten();
sync_git_overlay_remote_add(&repo, &name, &url)?;
let mut cfg = RemoteConfig::open(&repo).map_err(anyhow::Error::msg)?;
let default_was_empty = cfg.default_name().is_none();
cfg.add(&name, Remote { url: url.clone() })
.map_err(anyhow::Error::msg)?;
if default_was_empty
&& git_overlay_default_before
.as_deref()
.is_some_and(|default| default != name)
{
cfg.clear_default().map_err(anyhow::Error::msg)?;
}
let default = resolved_default_remote_name(&repo)?;
render_remote_mutation(
RemoteMutationOutput {
output_kind: "remote_add",
status: "completed",
action: "remote_add",
name,
url: Some(url),
default,
message: "Added remote".to_string(),
trust: build_repository_verification_state(&repo),
},
should_output_json(cli, Some(repo.config())),
)?;
Ok(())
}
RemoteCommands::Remove { name } => {
if !merged_remote_items(&repo)?.contains_key(&name) {
return Err(RecoveryAdvice::remote_not_found(&name).into());
}
sync_git_overlay_remote_remove(&repo, &name)?;
let mut cfg = RemoteConfig::open(&repo).map_err(anyhow::Error::msg)?;
match cfg.remove(&name) {
Ok(()) | Err(RemoteError::NotFound(_)) => {}
Err(err) => return Err(anyhow::Error::msg(err)),
}
render_remote_mutation(
RemoteMutationOutput {
output_kind: "remote_remove",
status: "completed",
action: "remote_remove",
name,
url: None,
default: resolved_default_remote_name(&repo)?,
message: "Removed remote".to_string(),
trust: build_repository_verification_state(&repo),
},
should_output_json(cli, Some(repo.config())),
)?;
Ok(())
}
RemoteCommands::SetDefault { name } => {
let items = merged_remote_items(&repo)?;
let (url, _source) = items
.get(&name)
.cloned()
.ok_or_else(|| RecoveryAdvice::remote_not_found(&name))?;
let mut cfg = RemoteConfig::open(&repo).map_err(anyhow::Error::msg)?;
if cfg.get(&name).is_err() {
cfg.add(&name, Remote { url }).map_err(anyhow::Error::msg)?;
}
cfg.set_default(&name).map_err(anyhow::Error::msg)?;
render_remote_mutation(
RemoteMutationOutput {
output_kind: "remote_set_default",
status: "completed",
action: "remote_set_default",
name: name.clone(),
url: None,
default: Some(name),
message: "Set default remote".to_string(),
trust: build_repository_verification_state(&repo),
},
should_output_json(cli, Some(repo.config())),
)?;
Ok(())
}
RemoteCommands::Show { name } => {
let items = merged_remote_items(&repo)?;
let default = resolved_default_remote_name(&repo)?;
let (url, source) = items
.get(&name)
.cloned()
.ok_or_else(|| RecoveryAdvice::remote_not_found(&name))?;
let is_default = default.as_deref() == Some(name.as_str());
let output = RemoteInfoOutput {
output_kind: Some("remote_show"),
name,
url,
source,
is_default,
};
render_remote_info(&output, should_output_json(cli, Some(repo.config())))?;
Ok(())
}
}
}
fn render_remote_mutation(output: RemoteMutationOutput, json: bool) -> Result<()> {
if json {
println!("{}", serde_json::to_string(&output)?);
} else {
println!(
"{} {} {}",
style::ok_marker(),
output.message.to_lowercase(),
style::bold(&output.name)
);
if !output.trust.recommended_action.is_empty() {
print_next(&output.trust.recommended_action);
}
}
Ok(())
}
fn render_remote_list(output: &RemoteListOutput, json: bool) -> Result<()> {
if json {
println!("{}", serde_json::to_string(output)?);
} else if output.remotes.is_empty() {
println!("{}", style::dim("No remotes configured"));
println!("{}", style::field("next", "heddle remote add <name> <url>"));
} else {
println!("{}", style::section("Remotes"));
for item in &output.remotes {
println!(
" {} {} {}",
style::bold(&item.name),
style::dim(&item.url),
style::dim(&format!(
"({}{})",
item.source,
if item.is_default { ", default" } else { "" }
))
);
}
}
Ok(())
}
fn render_remote_info(output: &RemoteInfoOutput, json: bool) -> Result<()> {
if json {
println!("{}", serde_json::to_string(output)?);
} else {
println!("{}", style::section("Remote"));
println!(" {}", style::field("name", &style::bold(&output.name)));
println!(" {}", style::field("url", &style::dim(&output.url)));
println!(" {}", style::field("source", &style::dim(&output.source)));
println!(
" {}",
style::field("default", if output.is_default { "yes" } else { "no" })
);
}
Ok(())
}
pub(crate) fn resolve_default_remote_name(
repo: &Repository,
requested: Option<&str>,
) -> Result<String> {
if let Some(requested) = requested {
return Ok(requested.to_string());
}
if let Some(default) = RemoteConfig::open(repo)
.map_err(anyhow::Error::msg)?
.default_name()
{
return Ok(default.to_string());
}
if repo.capability() == RepositoryCapability::GitOverlay
&& let Some(default) = git_overlay_default_remote_name(repo)
{
return Ok(default);
}
Ok("origin".to_string())
}
pub(crate) fn resolved_default_remote_name(repo: &Repository) -> Result<Option<String>> {
let cfg = RemoteConfig::open(repo).map_err(anyhow::Error::msg)?;
if let Some(default) = cfg.default_name() {
return Ok(Some(default.to_string()));
}
if repo.capability() == RepositoryCapability::GitOverlay {
return Ok(git_overlay_default_remote_name(repo));
}
Ok(None)
}
fn git_overlay_default_remote_name(repo: &Repository) -> Option<String> {
let git_remotes = git_overlay_config_remotes(repo);
if let Some(upstream_remote) = git_upstream_remote_name(repo) {
return Some(upstream_remote);
}
if git_remotes.contains_key("origin") {
return Some("origin".to_string());
}
if git_remotes.len() == 1 {
return git_remotes.keys().next().cloned();
}
None
}
fn git_upstream_remote_name(repo: &Repository) -> Option<String> {
let branch = repo.git_overlay_current_branch().ok().flatten()?;
let git = SleyRepository::discover(repo.root()).ok()?;
git.config_snapshot()
.ok()?
.get("branch", Some(&branch), "remote")
.map(str::to_string)
.filter(|remote| !remote.is_empty())
}
fn merged_remote_items(repo: &Repository) -> Result<BTreeMap<String, (String, String)>> {
let cfg = RemoteConfig::open(repo).map_err(anyhow::Error::msg)?;
let git_overlay_remotes = if repo.capability() == RepositoryCapability::GitOverlay {
git_overlay_config_remotes(repo)
} else {
BTreeMap::new()
};
let mut items: BTreeMap<String, (String, String)> = cfg
.list()
.into_iter()
.map(|(name, remote)| {
let source = configured_remote_source(repo, &remote.url);
(name, (remote.url, source.to_string()))
})
.collect();
if repo.capability() == RepositoryCapability::GitOverlay {
for (name, url) in git_overlay_remotes {
items
.entry(name)
.or_insert_with(|| (url, "git-overlay".to_string()));
}
}
Ok(items)
}
fn configured_remote_source(repo: &Repository, url: &str) -> &'static str {
if repo.capability() == RepositoryCapability::GitOverlay
&& local_remote_path(url).is_some_and(|path| is_local_git_repository(&path))
{
"git-overlay"
} else {
"heddle"
}
}
fn local_remote_path(url: &str) -> Option<std::path::PathBuf> {
match RemoteTarget::parse(url).ok()? {
RemoteTarget::Local(path) => Some(path),
RemoteTarget::Network { .. } => None,
}
}
fn is_local_git_repository(path: &Path) -> bool {
if path.join(".git").exists() {
return true;
}
path.join("HEAD").is_file() && path.join("objects").is_dir() && path.join("refs").is_dir()
}
fn plain_git_remote_items(root: &Path) -> BTreeMap<String, String> {
let Some(ctx) = GitConfigContext::discover(root) else {
return BTreeMap::new();
};
ctx.remotes(ctx.layered_paths())
}
fn default_remote_from_items(items: &BTreeMap<String, String>) -> Option<String> {
if items.contains_key("origin") {
Some("origin".to_string())
} else if items.len() == 1 {
items.keys().next().cloned()
} else {
None
}
}
fn git_overlay_config_remotes(repo: &Repository) -> BTreeMap<String, String> {
let Some(ctx) = GitConfigContext::discover(repo.root()) else {
return BTreeMap::new();
};
let mut paths = ctx.layered_paths();
paths.push(repo.heddle_dir().join("git").join("config"));
ctx.remotes(paths)
}
struct GitConfigContext {
git_dir: PathBuf,
common_dir: PathBuf,
branch: Option<String>,
}
impl GitConfigContext {
fn discover(root: &Path) -> Option<Self> {
let git = SleyRepository::discover(root).ok()?;
Some(Self {
git_dir: git.git_dir().to_path_buf(),
common_dir: git.common_dir().to_path_buf(),
branch: git
.head()
.ok()
.and_then(|head| head.symbolic_target.map(|name| name.to_string()))
.and_then(|name| name.strip_prefix("refs/heads/").map(str::to_string)),
})
}
fn layered_paths(&self) -> Vec<std::path::PathBuf> {
let mut paths = Vec::new();
if self.worktree_config_enabled() {
paths.push(self.git_dir.join("config.worktree"));
}
paths.push(self.git_dir.join("config"));
if self.common_dir != self.git_dir {
paths.push(self.common_dir.join("config"));
}
paths
}
fn worktree_config_enabled(&self) -> bool {
let mut paths = vec![self.git_dir.join("config")];
if self.common_dir != self.git_dir {
paths.push(self.common_dir.join("config"));
}
self.load(paths)
.and_then(|config| config.get_bool("extensions", None, "worktreeConfig"))
.unwrap_or(false)
}
fn write_file_for(&self, name: &str) -> Result<std::path::PathBuf> {
match self.defining_files_for(name).into_iter().next() {
Some(path) => {
if !self.owns_config_file(&path) {
anyhow::bail!(RecoveryAdvice::git_remote_in_included_config(name, &path));
}
Ok(path)
}
None => Ok(self.common_dir.join("config")),
}
}
fn remove_files_for(&self, name: &str) -> Result<Vec<std::path::PathBuf>> {
let files = self.defining_files_for(name);
for path in &files {
if !self.owns_config_file(path) {
anyhow::bail!(RecoveryAdvice::git_remote_in_included_config(name, path));
}
}
Ok(files)
}
fn defining_files_for(&self, name: &str) -> Vec<std::path::PathBuf> {
let mut files = Vec::new();
let Some(stack) = self.config_stack() else {
return files;
};
for entry in stack.entries.iter().rev() {
if entry.section.eq_ignore_ascii_case("remote")
&& entry.subsection.as_deref() == Some(name)
&& let Some(path) = config_entry_origin_path(entry)
&& !files.contains(&path)
{
files.push(path);
}
}
files
}
fn owns_config_file(&self, path: &Path) -> bool {
let target = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
[&self.git_dir, &self.common_dir].into_iter().any(|root| {
let root = root.canonicalize().unwrap_or_else(|_| root.clone());
target.starts_with(&root)
})
}
fn remotes(&self, paths: Vec<std::path::PathBuf>) -> BTreeMap<String, String> {
let mut remotes = BTreeMap::new();
for path in paths {
let Some(config) = self.load_one(&path, true) else {
continue;
};
for section in &config.sections {
if !section.name.eq_ignore_ascii_case("remote") {
continue;
}
let Some(name) = section.subsection.as_deref() else {
continue;
};
let Some(url) = config_section_value(section, "url") else {
continue;
};
remotes
.entry(name.to_string())
.or_insert_with(|| url.to_string());
}
}
remotes
}
fn load(&self, paths: Vec<PathBuf>) -> Option<GitConfig> {
let mut merged = GitConfig::default();
for path in paths.into_iter().rev() {
let Some(config) = self.load_one(&path, true) else {
continue;
};
merged.sections.extend(config.sections);
}
Some(merged)
}
fn config_stack(&self) -> Option<ConfigStack> {
let context = ConfigIncludeContext {
git_dir: Some(self.git_dir.clone()),
current_branch: self.branch.clone(),
};
let mut stack = ConfigStack::new();
for path in self.layered_paths().into_iter().rev() {
let scope = if path
.file_name()
.is_some_and(|name| name == "config.worktree")
{
ConfigScope::Worktree
} else {
ConfigScope::Local
};
stack.push_file(&path, scope, true, &context).ok()?;
}
Some(stack)
}
fn load_one(&self, path: &Path, follow_includes: bool) -> Option<GitConfig> {
let bytes = fs::read(path).ok()?;
let config = GitConfig::parse(&bytes).ok()?;
if !follow_includes {
return Some(config);
}
let base = path.parent().unwrap_or_else(|| Path::new("."));
config
.resolve_includes(
base,
&ConfigIncludeContext {
git_dir: Some(self.git_dir.clone()),
current_branch: self.branch.clone(),
},
)
.ok()
}
}
fn config_entry_origin_path(entry: &ConfigStackEntry) -> Option<PathBuf> {
(entry.origin.kind == ConfigOriginKind::File).then(|| PathBuf::from(&entry.origin.name))
}
fn config_section_value<'a>(
section: &'a sley::plumbing::sley_config::ConfigSection,
key: &str,
) -> Option<&'a str> {
section
.entries
.iter()
.rev()
.find(|entry| entry.key.eq_ignore_ascii_case(key))
.and_then(|entry| entry.value.as_deref())
}
fn sync_git_overlay_remote_add(repo: &Repository, name: &str, url: &str) -> Result<()> {
if repo.capability() != RepositoryCapability::GitOverlay {
return Ok(());
}
validate_git_overlay_remote_name(name)?;
let ctx = GitConfigContext::discover(repo.root())
.context("Git-overlay remote add requires a writable Git config")?;
upsert_git_remote_config(&ctx.write_file_for(name)?, name, url)
}
fn sync_git_overlay_remote_remove(repo: &Repository, name: &str) -> Result<()> {
if repo.capability() != RepositoryCapability::GitOverlay {
return Ok(());
}
let Some(ctx) = GitConfigContext::discover(repo.root()) else {
return Ok(());
};
for config_path in ctx.remove_files_for(name)? {
remove_git_remote_config(&config_path, name)?;
}
Ok(())
}
fn validate_git_overlay_remote_name(name: &str) -> Result<()> {
if name.trim().is_empty()
|| name.starts_with('-')
|| name.bytes().any(|byte| byte < 0x20 || byte == 0x7f)
|| name
.chars()
.any(|ch| matches!(ch, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\'))
|| name.contains("..")
|| name.contains("//")
|| name.starts_with('/')
|| name.ends_with('/')
|| name.starts_with('.')
|| name.ends_with(".lock")
{
anyhow::bail!(RecoveryAdvice::git_remote_name_invalid(name));
}
Ok(())
}
fn upsert_git_remote_config(config_path: &Path, name: &str, url: &str) -> Result<()> {
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)?;
}
let contents = fs::read_to_string(config_path).unwrap_or_default();
let mut contents = remove_git_config_named_section(&contents, "remote", name);
if !contents.ends_with('\n') && !contents.is_empty() {
contents.push('\n');
}
let fetch = format!("+refs/heads/*:refs/remotes/{name}/*");
contents.push_str(&format!(
"[remote \"{}\"]\n\turl = {}\n\tfetch = {}\n",
escape_git_config_section(name),
quote_git_config_value(url),
quote_git_config_value(&fetch)
));
write_file_atomic(config_path, contents.as_bytes())?;
Ok(())
}
fn remove_git_remote_config(config_path: &Path, name: &str) -> Result<()> {
if !config_path.exists() {
return Ok(());
}
let contents = fs::read_to_string(config_path)
.with_context(|| format!("reading git config at {}", config_path.display()))?;
let updated = remove_git_config_named_section(&contents, "remote", name);
if updated == contents {
return Ok(());
}
write_file_atomic(config_path, updated.as_bytes())?;
Ok(())
}
fn remove_git_config_named_section(contents: &str, section: &str, subsection_name: &str) -> String {
let mut output = Vec::new();
let mut skipping = false;
for line in contents.lines() {
if let Some(name) = parse_git_config_subsection_name(line, section) {
skipping = name == subsection_name;
} else if is_git_config_section_header(line) {
skipping = false;
}
if !skipping {
output.push(line);
}
}
let mut text = output.join("\n");
if contents.ends_with('\n') && !text.is_empty() {
text.push('\n');
}
text
}
fn parse_git_config_subsection_name(line: &str, section: &str) -> Option<String> {
let trimmed = line.trim_start();
let end = trimmed.find(']')?;
let inner = trimmed.strip_prefix('[')?[..end - 1].trim();
let (parsed_section, rest) = inner
.split_once(char::is_whitespace)
.map(|(section, rest)| (section, Some(rest.trim_start())))
.unwrap_or((inner, None));
if !parsed_section.eq_ignore_ascii_case(section) {
if let Some((dotted_section, dotted_subsection)) = inner.split_once('.')
&& dotted_section.eq_ignore_ascii_case(section)
{
return Some(dotted_subsection.to_string());
}
return None;
}
let rest = rest?;
let quoted = rest.strip_prefix('"')?.strip_suffix('"')?;
unescape_git_config_string(quoted)
}
fn is_git_config_section_header(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed.starts_with('[') && trimmed.contains(']')
}
fn escape_git_config_section(value: &str) -> String {
escape_git_config_value(value)
}
fn escape_git_config_value(value: &str) -> String {
let mut out = String::new();
for ch in value.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
'\r' => out.push_str("\\r"),
'\u{0008}' => out.push_str("\\b"),
ch => out.push(ch),
}
}
out
}
fn quote_git_config_value(value: &str) -> String {
format!("\"{}\"", escape_git_config_value(value))
}
fn unescape_git_config_string(value: &str) -> Option<String> {
let mut out = String::new();
let mut chars = value.chars();
while let Some(ch) = chars.next() {
if ch != '\\' {
out.push(ch);
continue;
}
match chars.next()? {
'\\' => out.push('\\'),
'"' => out.push('"'),
'n' => out.push('\n'),
't' => out.push('\t'),
'r' => out.push('\r'),
'b' => out.push('\u{0008}'),
escaped => out.push(escaped),
}
}
Some(out)
}
#[cfg(feature = "client")]
struct PullNetworkOptions<'a> {
addr: SocketAddr,
repo_path: Option<&'a str>,
user_config: &'a UserConfig,
server_key: Option<String>,
remote_thread: &'a str,
local_thread: Option<&'a str>,
lazy: bool,
cli: &'a Cli,
}
#[cfg(test)]
mod tests {
use super::*;
fn init_git(root: &Path) {
SleyRepository::init(root).expect("init git repo");
}
#[test]
fn parses_quoted_url_with_equals_and_strips_quotes() {
let tmp = tempfile::TempDir::new().unwrap();
init_git(tmp.path());
fs::write(
tmp.path().join(".git").join("config"),
"[remote \"origin\"]\n\turl = \"https://example.com/repo?ref=main&a=b\"\n",
)
.unwrap();
let remotes = plain_git_remote_items(tmp.path());
assert_eq!(
remotes.get("origin").map(String::as_str),
Some("https://example.com/repo?ref=main&a=b"),
);
}
#[test]
fn strips_inline_comments_from_url() {
let tmp = tempfile::TempDir::new().unwrap();
init_git(tmp.path());
fs::write(
tmp.path().join(".git").join("config"),
"[remote \"origin\"]\n\turl = https://example.com/repo ; trailing comment\n",
)
.unwrap();
let remotes = plain_git_remote_items(tmp.path());
assert_eq!(
remotes.get("origin").map(String::as_str),
Some("https://example.com/repo"),
);
}
#[test]
fn follows_include_directives() {
let tmp = tempfile::TempDir::new().unwrap();
init_git(tmp.path());
let git_dir = tmp.path().join(".git");
fs::write(
git_dir.join("extra.config"),
"[remote \"upstream\"]\n\turl = https://example.com/upstream\n",
)
.unwrap();
fs::write(git_dir.join("config"), "[include]\n\tpath = extra.config\n").unwrap();
let remotes = plain_git_remote_items(tmp.path());
assert_eq!(
remotes.get("upstream").map(String::as_str),
Some("https://example.com/upstream"),
);
}
#[test]
fn worktree_config_overrides_local_when_extension_enabled() {
let tmp = tempfile::TempDir::new().unwrap();
init_git(tmp.path());
let git_dir = tmp.path().join(".git");
fs::write(
git_dir.join("config"),
"[extensions]\n\tworktreeConfig = true\n\
[remote \"origin\"]\n\turl = https://example.com/local\n",
)
.unwrap();
fs::write(
git_dir.join("config.worktree"),
"[remote \"origin\"]\n\turl = https://example.com/worktree\n",
)
.unwrap();
let remotes = plain_git_remote_items(tmp.path());
assert_eq!(
remotes.get("origin").map(String::as_str),
Some("https://example.com/worktree"),
);
}
#[test]
fn ignores_worktree_config_when_extension_disabled() {
let tmp = tempfile::TempDir::new().unwrap();
init_git(tmp.path());
let git_dir = tmp.path().join(".git");
fs::write(
git_dir.join("config"),
"[remote \"origin\"]\n\turl = https://example.com/local\n",
)
.unwrap();
fs::write(
git_dir.join("config.worktree"),
"[remote \"origin\"]\n\turl = https://example.com/worktree\n",
)
.unwrap();
let remotes = plain_git_remote_items(tmp.path());
assert_eq!(
remotes.get("origin").map(String::as_str),
Some("https://example.com/local"),
);
}
#[test]
fn remove_clears_worktree_layer_when_extension_enabled() {
let tmp = tempfile::TempDir::new().unwrap();
init_git(tmp.path());
let git_dir = tmp.path().join(".git");
fs::write(
git_dir.join("config"),
"[extensions]\n\tworktreeConfig = true\n\
[remote \"origin\"]\n\turl = https://example.com/common\n",
)
.unwrap();
fs::write(
git_dir.join("config.worktree"),
"[remote \"origin\"]\n\turl = https://example.com/worktree\n",
)
.unwrap();
let ctx = GitConfigContext::discover(tmp.path()).unwrap();
for path in ctx.remove_files_for("origin").unwrap() {
remove_git_remote_config(&path, "origin").unwrap();
}
assert!(!plain_git_remote_items(tmp.path()).contains_key("origin"));
}
#[test]
fn add_targets_worktree_layer_so_next_read_reflects_it() {
let tmp = tempfile::TempDir::new().unwrap();
init_git(tmp.path());
let git_dir = tmp.path().join(".git");
fs::write(
git_dir.join("config"),
"[extensions]\n\tworktreeConfig = true\n",
)
.unwrap();
fs::write(
git_dir.join("config.worktree"),
"[remote \"origin\"]\n\turl = https://example.com/old\n",
)
.unwrap();
let ctx = GitConfigContext::discover(tmp.path()).unwrap();
upsert_git_remote_config(
&ctx.write_file_for("origin").unwrap(),
"origin",
"https://example.com/new",
)
.unwrap();
assert_eq!(
plain_git_remote_items(tmp.path())
.get("origin")
.map(String::as_str),
Some("https://example.com/new"),
);
}
#[test]
fn remove_clears_remote_defined_via_include_path() {
let tmp = tempfile::TempDir::new().unwrap();
init_git(tmp.path());
let git_dir = tmp.path().join(".git");
fs::write(
git_dir.join("extra.config"),
"[remote \"upstream\"]\n\turl = https://example.com/upstream\n",
)
.unwrap();
fs::write(git_dir.join("config"), "[include]\n\tpath = extra.config\n").unwrap();
assert!(plain_git_remote_items(tmp.path()).contains_key("upstream"));
let ctx = GitConfigContext::discover(tmp.path()).unwrap();
for path in ctx.remove_files_for("upstream").unwrap() {
remove_git_remote_config(&path, "upstream").unwrap();
}
assert!(!plain_git_remote_items(tmp.path()).contains_key("upstream"));
}
#[test]
fn write_to_included_remote_targets_the_defining_file() {
let tmp = tempfile::TempDir::new().unwrap();
init_git(tmp.path());
let git_dir = tmp.path().join(".git");
fs::write(
git_dir.join("extra.config"),
"[remote \"origin\"]\n\turl = https://example.com/old\n",
)
.unwrap();
fs::write(git_dir.join("config"), "[include]\n\tpath = extra.config\n").unwrap();
let ctx = GitConfigContext::discover(tmp.path()).unwrap();
let target = ctx.write_file_for("origin").unwrap();
assert_eq!(target, git_dir.join("extra.config"));
upsert_git_remote_config(&target, "origin", "https://example.com/new").unwrap();
assert_eq!(
plain_git_remote_items(tmp.path())
.get("origin")
.map(String::as_str),
Some("https://example.com/new"),
);
}
#[test]
fn write_to_remote_in_external_include_errors_rather_than_no_ops() {
let tmp = tempfile::TempDir::new().unwrap();
init_git(tmp.path());
let git_dir = tmp.path().join(".git");
let external = tmp.path().join("external.config");
fs::write(
&external,
"[remote \"origin\"]\n\turl = https://example.com/external\n",
)
.unwrap();
fs::write(
git_dir.join("config"),
format!("[include]\n\tpath = {}\n", external.display()),
)
.unwrap();
let ctx = GitConfigContext::discover(tmp.path()).unwrap();
assert!(ctx.write_file_for("origin").is_err());
assert!(ctx.remove_files_for("origin").is_err());
}
#[test]
fn add_new_remote_targets_common_layer() {
let tmp = tempfile::TempDir::new().unwrap();
init_git(tmp.path());
let git_dir = tmp.path().join(".git");
fs::write(
git_dir.join("config"),
"[extensions]\n\tworktreeConfig = true\n",
)
.unwrap();
let ctx = GitConfigContext::discover(tmp.path()).unwrap();
assert_eq!(
ctx.write_file_for("origin").unwrap(),
git_dir.join("config")
);
upsert_git_remote_config(
&ctx.write_file_for("origin").unwrap(),
"origin",
"https://example.com/new",
)
.unwrap();
assert_eq!(
plain_git_remote_items(tmp.path())
.get("origin")
.map(String::as_str),
Some("https://example.com/new"),
);
}
#[test]
fn remove_clears_comment_suffixed_remote_header() {
let tmp = tempfile::TempDir::new().unwrap();
init_git(tmp.path());
let git_dir = tmp.path().join(".git");
fs::write(
git_dir.join("config"),
"[remote \"origin\"] # primary mirror\n\turl = https://example.com/repo\n",
)
.unwrap();
assert!(plain_git_remote_items(tmp.path()).contains_key("origin"));
let ctx = GitConfigContext::discover(tmp.path()).unwrap();
for path in ctx.remove_files_for("origin").unwrap() {
remove_git_remote_config(&path, "origin").unwrap();
}
assert!(!plain_git_remote_items(tmp.path()).contains_key("origin"));
}
#[test]
fn remove_clears_dotted_remote_header() {
let tmp = tempfile::TempDir::new().unwrap();
init_git(tmp.path());
let git_dir = tmp.path().join(".git");
fs::write(
git_dir.join("config"),
"[remote.origin]\n\turl = https://example.com/repo\n",
)
.unwrap();
assert!(plain_git_remote_items(tmp.path()).contains_key("origin"));
let ctx = GitConfigContext::discover(tmp.path()).unwrap();
for path in ctx.remove_files_for("origin").unwrap() {
remove_git_remote_config(&path, "origin").unwrap();
}
assert!(!plain_git_remote_items(tmp.path()).contains_key("origin"));
}
#[test]
fn upsert_replaces_comment_suffixed_remote_header_without_duplicating() {
let tmp = tempfile::TempDir::new().unwrap();
init_git(tmp.path());
let git_dir = tmp.path().join(".git");
fs::write(
git_dir.join("config"),
"[remote \"origin\"] # primary mirror\n\turl = https://example.com/old\n",
)
.unwrap();
let ctx = GitConfigContext::discover(tmp.path()).unwrap();
upsert_git_remote_config(
&ctx.write_file_for("origin").unwrap(),
"origin",
"https://example.com/new",
)
.unwrap();
assert_eq!(
plain_git_remote_items(tmp.path())
.get("origin")
.map(String::as_str),
Some("https://example.com/new"),
);
}
#[test]
fn upsert_replaces_dotted_remote_header() {
let tmp = tempfile::TempDir::new().unwrap();
init_git(tmp.path());
let git_dir = tmp.path().join(".git");
fs::write(
git_dir.join("config"),
"[remote.origin]\n\turl = https://example.com/old\n",
)
.unwrap();
let ctx = GitConfigContext::discover(tmp.path()).unwrap();
upsert_git_remote_config(
&ctx.write_file_for("origin").unwrap(),
"origin",
"https://example.com/new",
)
.unwrap();
assert_eq!(
plain_git_remote_items(tmp.path())
.get("origin")
.map(String::as_str),
Some("https://example.com/new"),
);
}
}