use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
use anyhow::{Result, anyhow};
use cli_shared::remote::{RemoteConfig, RemoteTarget};
use refs::Head;
use repo::{Repository, RepositoryCapability};
use serde::Serialize;
use sley::{
GitConfig, Repository as SleyRepository,
plumbing::sley_config::{
ConfigIncludeContext, ConfigOriginKind, ConfigScope, ConfigStack, ConfigStackEntry,
},
};
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct RemoteListReport {
pub output_kind: &'static str,
pub remotes: Vec<RemoteInfo>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct RemoteInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub output_kind: Option<&'static str>,
pub name: String,
pub url: String,
pub source: String,
pub is_default: bool,
}
impl RemoteListReport {
pub fn empty() -> Self {
Self {
output_kind: "remote_list",
remotes: Vec::new(),
}
}
}
pub fn list_remotes(repo: &Repository) -> Result<RemoteListReport> {
let items = merged_remote_items(repo)?;
let default = resolved_default_remote_name(repo)?;
Ok(RemoteListReport {
output_kind: "remote_list",
remotes: items
.into_iter()
.map(|(name, (url, source))| {
let is_default = default.as_deref() == Some(name.as_str());
RemoteInfo {
output_kind: None,
name,
url,
source,
is_default,
}
})
.collect(),
})
}
pub fn list_plain_git_remotes(root: &Path) -> RemoteListReport {
let items = plain_git_remote_items(root);
let default = plain_git_default_remote_name(root, &items);
RemoteListReport {
output_kind: "remote_list",
remotes: items
.into_iter()
.map(|(name, url)| {
let is_default = default.as_deref() == Some(name.as_str());
RemoteInfo {
output_kind: None,
name,
url,
source: "git".to_string(),
is_default,
}
})
.collect(),
}
}
pub fn show_remote(repo: &Repository, name: &str) -> Result<Option<RemoteInfo>> {
let items = merged_remote_items(repo)?;
let default = resolved_default_remote_name(repo)?;
let Some((url, source)) = items.get(name).cloned() else {
return Ok(None);
};
Ok(Some(RemoteInfo {
output_kind: Some("remote_show"),
name: name.to_string(),
url,
source,
is_default: default.as_deref() == Some(name),
}))
}
pub fn show_plain_git_remote(root: &Path, name: &str) -> Option<RemoteInfo> {
let items = plain_git_remote_items(root);
let default = plain_git_default_remote_name(root, &items);
let url = items.get(name)?.clone();
Some(RemoteInfo {
output_kind: Some("remote_show"),
name: name.to_string(),
url,
source: "git".to_string(),
is_default: default.as_deref() == Some(name),
})
}
pub fn resolve_default_remote_name(repo: &Repository, requested: Option<&str>) -> Result<String> {
if let Some(requested) = requested {
return Ok(requested.to_string());
}
if repo.capability() == RepositoryCapability::GitOverlay
&& let Some(default) = git_overlay_default_remote_name(repo)
{
return Ok(default);
}
if let Some(default) = RemoteConfig::open(repo)
.map_err(anyhow::Error::new)?
.default_name()
{
return Ok(default.to_string());
}
Err(anyhow!(
"No default remote is configured; pass a remote or configure one first"
))
}
pub fn resolve_default_push_remote_name(
repo: &Repository,
requested: Option<&str>,
) -> Result<String> {
if let Some(requested) = requested {
return Ok(requested.to_string());
}
if repo.capability() != RepositoryCapability::GitOverlay {
return resolve_default_remote_name(repo, None);
}
git_overlay_default_push_remote_name(repo).ok_or_else(|| {
anyhow!("No default push remote is configured; pass a remote or configure one first")
})
}
pub fn resolved_default_remote_name(repo: &Repository) -> Result<Option<String>> {
if repo.capability() == RepositoryCapability::GitOverlay {
return Ok(git_overlay_default_remote_name(repo));
}
let cfg = RemoteConfig::open(repo).map_err(anyhow::Error::new)?;
if let Some(default) = cfg.default_name() {
return Ok(Some(default.to_string()));
}
Ok(None)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HostedPushPlan {
NativePerThreadFanout,
GitOverlayMirror,
NativeSingleThread,
}
pub fn all_threads_uses_single_mirror_push(capability: RepositoryCapability) -> bool {
capability == RepositoryCapability::GitOverlay
}
pub fn plan_hosted_push(capability: RepositoryCapability, all_threads: bool) -> HostedPushPlan {
if all_threads && !all_threads_uses_single_mirror_push(capability) {
HostedPushPlan::NativePerThreadFanout
} else if capability == RepositoryCapability::GitOverlay {
HostedPushPlan::GitOverlayMirror
} else {
HostedPushPlan::NativeSingleThread
}
}
pub fn uses_git_overlay_mirror_rpc(capability: RepositoryCapability) -> bool {
capability == RepositoryCapability::GitOverlay
}
pub fn uses_local_git_overlay_transport(
capability: RepositoryCapability,
uses_hosted_network: bool,
) -> bool {
capability == RepositoryCapability::GitOverlay && !uses_hosted_network
}
pub fn default_push_thread_name(requested: Option<&str>, head: &Head) -> String {
if let Some(requested) = requested {
return requested.to_string();
}
match head {
Head::Attached { thread } => thread.to_string(),
Head::Detached { .. } => "main".to_string(),
}
}
pub fn default_pull_thread_name(
explicit_thread: Option<&str>,
capability: RepositoryCapability,
head: &Head,
) -> String {
if let Some(thread) = explicit_thread {
return thread.to_string();
}
if capability == RepositoryCapability::GitOverlay
&& let Head::Attached { thread } = head
{
return thread.to_string();
}
"main".to_string()
}
pub fn git_overlay_current_thread_push_ok(
all_threads: bool,
requested: Option<&str>,
attached: Option<&str>,
) -> bool {
if all_threads {
return true;
}
match requested {
None => true,
Some(name) => attached == Some(name),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RemotePreflightBlocker {
MissingRemote,
TransportMismatch,
GitOverlayThreadMismatch {
requested: String,
attached: Option<String>,
},
}
impl std::fmt::Display for RemotePreflightBlocker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingRemote => write!(f, "no remote configured"),
Self::TransportMismatch => {
write!(f, "remote transport does not match repository capability")
}
Self::GitOverlayThreadMismatch {
requested,
attached,
} => {
let attached_label = attached
.as_deref()
.map(|t| format!("'{t}'"))
.unwrap_or_else(|| "detached HEAD".to_string());
write!(
f,
"git-overlay push targets the attached thread; requested '{requested}' but HEAD is {attached_label}"
)
}
}
}
}
impl std::error::Error for RemotePreflightBlocker {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PushPlanRequest {
pub capability: RepositoryCapability,
pub uses_hosted_network: bool,
pub remote: Option<String>,
pub has_default_remote: bool,
pub thread: Option<String>,
pub all_threads: bool,
pub force: bool,
pub head: Head,
pub native_local_heddle_target: bool,
pub transport_mismatch: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PushPath {
LocalGitOverlayRefs { all_threads: bool },
LocalNativeHeddle { all_threads: bool },
NativeRemote {
hosted: HostedPushPlan,
uses_mirror_rpc: bool,
native_all_threads_fanout: bool,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PushPlan {
pub remote: Option<String>,
pub all_threads: bool,
pub force: bool,
pub track_name: String,
pub uses_local_git_overlay: bool,
pub hosted: HostedPushPlan,
pub uses_git_overlay_mirror_rpc: bool,
pub native_all_threads_fanout: bool,
pub path: PushPath,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PullPlanRequest {
pub capability: RepositoryCapability,
pub uses_hosted_network: bool,
pub remote: Option<String>,
pub has_default_remote: bool,
pub thread: Option<String>,
pub local_thread: Option<String>,
pub head: Head,
pub transport_mismatch: bool,
pub lazy: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PullPlan {
pub remote: Option<String>,
pub remote_thread: String,
pub local_thread: Option<String>,
pub uses_local_git_overlay: bool,
pub will_materialize: bool,
pub requires_clean_worktree: bool,
pub lazy: bool,
}
pub fn remote_missing_blocker(
remote: Option<&str>,
has_default_remote: bool,
) -> Option<RemotePreflightBlocker> {
if remote.is_none() && !has_default_remote {
Some(RemotePreflightBlocker::MissingRemote)
} else {
None
}
}
pub fn transport_mismatch_blocker(
uses_local_git_overlay: bool,
transport_mismatch: bool,
) -> Option<RemotePreflightBlocker> {
if !uses_local_git_overlay && transport_mismatch {
Some(RemotePreflightBlocker::TransportMismatch)
} else {
None
}
}
pub fn git_overlay_thread_mismatch_blocker(
all_threads: bool,
requested: Option<&str>,
attached: Option<&str>,
) -> Option<RemotePreflightBlocker> {
if git_overlay_current_thread_push_ok(all_threads, requested, attached) {
None
} else {
Some(RemotePreflightBlocker::GitOverlayThreadMismatch {
requested: requested.unwrap_or("").to_string(),
attached: attached.map(str::to_string),
})
}
}
pub fn pull_will_materialize(local_thread: Option<&str>, remote_thread: &str, head: &Head) -> bool {
let track = local_thread.unwrap_or(remote_thread);
match head {
Head::Attached { thread } => thread == track,
Head::Detached { .. } => local_thread.is_none(),
}
}
pub fn pull_requires_clean_worktree(uses_local_git_overlay: bool, will_materialize: bool) -> bool {
uses_local_git_overlay || will_materialize
}
pub fn plan_push(request: &PushPlanRequest) -> Result<PushPlan, RemotePreflightBlocker> {
if let Some(blocker) =
remote_missing_blocker(request.remote.as_deref(), request.has_default_remote)
{
return Err(blocker);
}
let uses_local =
uses_local_git_overlay_transport(request.capability, request.uses_hosted_network);
let track_name = default_push_thread_name(request.thread.as_deref(), &request.head);
let hosted = plan_hosted_push(request.capability, request.all_threads);
let uses_mirror = uses_git_overlay_mirror_rpc(request.capability);
let native_fanout = matches!(hosted, HostedPushPlan::NativePerThreadFanout);
if uses_local {
if request.native_local_heddle_target {
return Ok(PushPlan {
remote: request.remote.clone(),
all_threads: request.all_threads,
force: request.force,
track_name,
uses_local_git_overlay: true,
hosted,
uses_git_overlay_mirror_rpc: uses_mirror,
native_all_threads_fanout: native_fanout,
path: PushPath::LocalNativeHeddle {
all_threads: request.all_threads,
},
});
}
let attached = match &request.head {
Head::Attached { thread } => Some(thread.as_str()),
Head::Detached { .. } => None,
};
if let Some(blocker) = git_overlay_thread_mismatch_blocker(
request.all_threads,
request.thread.as_deref(),
attached,
) {
return Err(blocker);
}
return Ok(PushPlan {
remote: request.remote.clone(),
all_threads: request.all_threads,
force: request.force,
track_name,
uses_local_git_overlay: true,
hosted,
uses_git_overlay_mirror_rpc: uses_mirror,
native_all_threads_fanout: native_fanout,
path: PushPath::LocalGitOverlayRefs {
all_threads: request.all_threads,
},
});
}
if let Some(blocker) = transport_mismatch_blocker(false, request.transport_mismatch) {
return Err(blocker);
}
Ok(PushPlan {
remote: request.remote.clone(),
all_threads: request.all_threads,
force: request.force,
track_name,
uses_local_git_overlay: false,
hosted,
uses_git_overlay_mirror_rpc: uses_mirror,
native_all_threads_fanout: native_fanout,
path: PushPath::NativeRemote {
hosted,
uses_mirror_rpc: uses_mirror,
native_all_threads_fanout: native_fanout,
},
})
}
pub fn plan_pull(request: &PullPlanRequest) -> Result<PullPlan, RemotePreflightBlocker> {
if let Some(blocker) =
remote_missing_blocker(request.remote.as_deref(), request.has_default_remote)
{
return Err(blocker);
}
let uses_local =
uses_local_git_overlay_transport(request.capability, request.uses_hosted_network);
if let Some(blocker) = transport_mismatch_blocker(uses_local, request.transport_mismatch) {
return Err(blocker);
}
let remote_thread =
default_pull_thread_name(request.thread.as_deref(), request.capability, &request.head);
let will_materialize = pull_will_materialize(
request.local_thread.as_deref(),
&remote_thread,
&request.head,
);
let requires_clean = pull_requires_clean_worktree(uses_local, will_materialize);
Ok(PullPlan {
remote: request.remote.clone(),
remote_thread,
local_thread: request.local_thread.clone(),
uses_local_git_overlay: uses_local,
will_materialize,
requires_clean_worktree: requires_clean,
lazy: request.lazy,
})
}
pub const GIT_NOTES_REF: &str = "refs/notes/heddle";
pub const GIT_NOTES_VISIBILITY_WARNING: &str =
"ordinary `git log --all` may show Heddle metadata commits from refs/notes/heddle";
pub const FORCE_DISCARD_WARNING: &str = "remote refs may be moved back to match local Heddle state; remote commits not reachable from this checkout can be discarded";
pub const COMMITS_SEEN_SCOPE: &str = "branches_and_heddle_notes";
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct GitRemoteConfigured {
pub name: String,
pub url: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct GitUpstreamConfigured {
pub branch: String,
pub remote: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitOverlayPushTracking {
pub remote_name: String,
pub configured_remote: Option<GitRemoteConfigured>,
pub upstream_branch: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct PushOutcome {
pub output_kind: &'static str,
pub action: &'static str,
pub status: &'static str,
pub success: bool,
pub pushed: bool,
pub changed: bool,
pub transport: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub push_scope: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ref_scope: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub git_notes_ref: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refs_written: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub git_notes_visibility_warning: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub git_tracking_remote: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub git_remote_configured: Option<GitRemoteConfigured>,
#[serde(skip_serializing_if = "Option::is_none")]
pub git_upstream_configured: Option<GitUpstreamConfigured>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags_included: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub force: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub force_discard_warning: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub objects: Option<usize>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct PullOutcome {
pub output_kind: &'static str,
pub action: &'static str,
pub status: &'static str,
pub success: bool,
pub pulled: bool,
pub changed: bool,
pub transport: &'static str,
pub remote: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub old_git_head: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_git_head: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub old_state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub states_created: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub commits_seen: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub commits_seen_scope: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub materialized_checkout: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub changed_path_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub changed_paths: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub objects: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PushExecutionFacts {
GitOverlayRefs {
remote_name: String,
current_thread: Option<String>,
refs_written: Vec<String>,
tracking: Option<GitOverlayPushTracking>,
},
HeddleSingle {
state: Option<String>,
objects: Option<usize>,
},
HeddleAllThreads {
pushed_threads: Vec<String>,
failed_threads: Vec<String>,
objects: usize,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PullExecutionFacts {
GitOverlay {
remote: String,
branch: Option<String>,
old_git_head: Option<String>,
new_git_head: Option<String>,
old_state: Option<String>,
new_state: Option<String>,
changed: bool,
states_created: usize,
commits_seen: usize,
materialized_checkout: bool,
changed_paths: Vec<String>,
},
Heddle {
changed: bool,
remote: String,
thread: String,
state: Option<String>,
objects: Option<usize>,
},
}
pub fn push_scope_label(all_threads: bool) -> &'static str {
if all_threads {
"all_threads"
} else {
"current_thread"
}
}
pub fn git_overlay_ref_scope(all_threads: bool) -> &'static str {
if all_threads {
"all_threads_tags_and_heddle_notes"
} else {
"branch_and_heddle_notes"
}
}
pub fn push_status(ok: bool) -> &'static str {
if ok { "pushed" } else { "partial" }
}
pub fn pull_status(changed: bool) -> &'static str {
if changed { "updated" } else { "up_to_date" }
}
pub fn build_push_outcome(plan: &PushPlan, facts: PushExecutionFacts) -> PushOutcome {
match facts {
PushExecutionFacts::GitOverlayRefs {
remote_name,
current_thread,
refs_written,
tracking,
} => {
let all_threads = plan.all_threads;
let force = plan.force;
let tracking_remote = tracking.as_ref().map(|t| t.remote_name.clone());
let configured_remote = tracking.as_ref().and_then(|t| t.configured_remote.clone());
let upstream_configured = tracking.as_ref().and_then(|t| {
t.upstream_branch
.as_ref()
.map(|branch| GitUpstreamConfigured {
branch: branch.clone(),
remote: tracking_remote
.clone()
.unwrap_or_else(|| "origin".to_string()),
})
});
PushOutcome {
output_kind: "push",
action: "push",
status: push_status(true),
success: true,
pushed: true,
changed: true,
transport: "git",
remote: Some(remote_name),
push_scope: Some(push_scope_label(all_threads)),
ref_scope: Some(git_overlay_ref_scope(all_threads)),
git_notes_ref: Some(GIT_NOTES_REF),
refs_written: Some(refs_written),
git_notes_visibility_warning: Some(GIT_NOTES_VISIBILITY_WARNING),
git_tracking_remote: tracking_remote,
git_remote_configured: configured_remote,
git_upstream_configured: upstream_configured,
tags_included: Some(all_threads),
force: Some(force),
force_discard_warning: force.then_some(FORCE_DISCARD_WARNING),
thread: current_thread,
state: None,
objects: None,
}
}
PushExecutionFacts::HeddleSingle { state, objects } => PushOutcome {
output_kind: "push",
action: "push",
status: push_status(true),
success: true,
pushed: true,
changed: true,
transport: "heddle",
remote: None,
push_scope: None,
ref_scope: None,
git_notes_ref: None,
refs_written: None,
git_notes_visibility_warning: None,
git_tracking_remote: None,
git_remote_configured: None,
git_upstream_configured: None,
tags_included: None,
force: None,
force_discard_warning: None,
thread: None,
state,
objects,
},
PushExecutionFacts::HeddleAllThreads {
mut pushed_threads,
failed_threads,
objects,
} => {
let ok = failed_threads.is_empty();
pushed_threads.sort();
PushOutcome {
output_kind: "push",
action: "push",
status: push_status(ok),
success: ok,
pushed: ok,
changed: true,
transport: "heddle",
remote: None,
push_scope: Some(push_scope_label(true)),
ref_scope: None,
git_notes_ref: None,
refs_written: Some(pushed_threads),
git_notes_visibility_warning: None,
git_tracking_remote: None,
git_remote_configured: None,
git_upstream_configured: None,
tags_included: None,
force: None,
force_discard_warning: None,
thread: None,
state: None,
objects: Some(objects),
}
}
}
}
pub fn build_pull_outcome(_plan: Option<&PullPlan>, facts: PullExecutionFacts) -> PullOutcome {
match facts {
PullExecutionFacts::GitOverlay {
remote,
branch,
old_git_head,
new_git_head,
old_state,
new_state,
changed,
states_created,
commits_seen,
materialized_checkout,
changed_paths,
} => {
let path_count = changed_paths.len();
PullOutcome {
output_kind: "pull",
action: "pull",
status: pull_status(changed),
success: true,
pulled: changed,
changed,
transport: "git",
remote,
branch,
old_git_head,
new_git_head,
old_state,
new_state,
states_created: Some(states_created),
commits_seen: Some(commits_seen),
commits_seen_scope: Some(COMMITS_SEEN_SCOPE),
materialized_checkout: Some(materialized_checkout),
changed_path_count: Some(path_count),
changed_paths: Some(changed_paths),
thread: None,
state: None,
objects: None,
}
}
PullExecutionFacts::Heddle {
changed,
remote,
thread,
state,
objects,
} => PullOutcome {
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,
},
}
}
pub fn summarize_push_outcome(outcome: &PushOutcome) -> String {
let remote = outcome.remote.as_deref().unwrap_or("remote");
match outcome.transport {
"git" => {
let scope = outcome.push_scope.unwrap_or("current_thread");
let refs = outcome.refs_written.as_ref().map(|r| r.len()).unwrap_or(0);
if outcome.force == Some(true) {
format!("force-pushed {scope} ({refs} refs) to {remote}")
} else {
format!("pushed {scope} ({refs} refs) to {remote}")
}
}
"heddle" if outcome.push_scope == Some("all_threads") => {
let n = outcome.refs_written.as_ref().map(|r| r.len()).unwrap_or(0);
if outcome.success {
format!("pushed {n} threads")
} else {
format!("partial push: {n} threads landed")
}
}
"heddle" => match (&outcome.state, outcome.objects) {
(Some(state), Some(objects)) => {
format!("pushed state {state} ({objects} objects)")
}
(Some(state), None) => format!("pushed state {state}"),
(None, Some(objects)) => format!("pushed ({objects} objects)"),
(None, None) => "pushed".to_string(),
},
other => format!("pushed via {other}"),
}
}
pub fn summarize_pull_outcome(outcome: &PullOutcome) -> String {
if !outcome.changed {
return format!("already up to date with {}", outcome.remote);
}
match outcome.transport {
"git" => {
let paths = outcome.changed_path_count.unwrap_or(0);
let states = outcome.states_created.unwrap_or(0);
format!(
"pulled from {} ({states} new states, {paths} changed paths)",
outcome.remote
)
}
"heddle" => {
let thread = outcome.thread.as_deref().unwrap_or("thread");
match (&outcome.state, outcome.objects) {
(Some(state), Some(objects)) => {
format!("pulled {thread} -> {state} ({objects} objects)")
}
(Some(state), None) => format!("pulled {thread} -> {state}"),
(None, Some(objects)) => format!("pulled {thread} ({objects} objects)"),
(None, None) => format!("pulled {thread} from {}", outcome.remote),
}
}
other => format!("pulled via {other} from {}", outcome.remote),
}
}
pub mod remote_advice_kind {
pub const REMOTE_NOT_CONFIGURED: &str = "remote_not_configured";
pub const REMOTE_TRANSPORT_MISMATCH: &str = "remote_transport_mismatch";
pub const GIT_OVERLAY_THREAD_MISMATCH: &str = "git_overlay_thread_mismatch";
pub const NAMED_THREAD_TIP_MISMATCH: &str = "named_thread_tip_mismatch";
pub const REMOTE_PUSH_FAILED: &str = "remote_push_failed";
pub const REMOTE_PULL_FAILED: &str = "remote_pull_failed";
pub const LOCAL_LAZY_PULL_UNSUPPORTED: &str = "local_lazy_pull_unsupported";
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PushFailure {
Preflight(RemotePreflightBlocker),
NamedThreadTipMismatch {
thread: String,
tip_short: String,
current_short: String,
},
RemoteFailed { track_name: String, error: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PullFailure {
Preflight(RemotePreflightBlocker),
LocalLazyUnsupported { source_path: String },
RemoteFailed {
remote_thread: String,
local_thread: Option<String>,
error: String,
},
}
impl PushFailure {
pub fn advice_kind(&self) -> &'static str {
match self {
Self::Preflight(RemotePreflightBlocker::MissingRemote) => {
remote_advice_kind::REMOTE_NOT_CONFIGURED
}
Self::Preflight(RemotePreflightBlocker::TransportMismatch) => {
remote_advice_kind::REMOTE_TRANSPORT_MISMATCH
}
Self::Preflight(RemotePreflightBlocker::GitOverlayThreadMismatch { .. }) => {
remote_advice_kind::GIT_OVERLAY_THREAD_MISMATCH
}
Self::NamedThreadTipMismatch { .. } => remote_advice_kind::NAMED_THREAD_TIP_MISMATCH,
Self::RemoteFailed { .. } => remote_advice_kind::REMOTE_PUSH_FAILED,
}
}
pub fn primary_command(&self) -> String {
match self {
Self::Preflight(RemotePreflightBlocker::MissingRemote) => {
"heddle remote add <name> <url>".to_string()
}
Self::Preflight(RemotePreflightBlocker::TransportMismatch) => {
"heddle clone <remote> <fresh-path>".to_string()
}
Self::Preflight(RemotePreflightBlocker::GitOverlayThreadMismatch {
requested, ..
}) => format!("heddle thread switch {requested} && heddle push"),
Self::NamedThreadTipMismatch { thread, .. } => {
format!("heddle thread switch {thread}")
}
Self::RemoteFailed { track_name, .. } => format!("heddle push {track_name}"),
}
}
pub fn recovery_hint(&self) -> String {
match self {
Self::Preflight(RemotePreflightBlocker::MissingRemote) => {
"Add a remote with `heddle remote add <name> <url>`, inspect remotes with `heddle remote list`, or choose one with `heddle remote set-default <name>`. Ad-hoc targets are supported without configuration: `heddle push <remote>` accepts a remote name, URL, local path, or hosted address positionally.".to_string()
}
Self::Preflight(RemotePreflightBlocker::TransportMismatch) => {
"Use a Heddle-native remote here, or clone/adopt that Git remote in a Git-overlay checkout.".to_string()
}
Self::Preflight(RemotePreflightBlocker::GitOverlayThreadMismatch {
requested, ..
}) => format!(
"Switch to the requested thread with `heddle thread switch {requested} && heddle push`, or pass `--all-threads`."
),
Self::NamedThreadTipMismatch { thread, .. } => format!(
"Switch to that thread's checkout (`heddle thread switch {thread}`), or pass `--force` to push the current state under '{thread}'."
),
Self::RemoteFailed { track_name, .. } => format!(
"Inspect `heddle verify`, then retry with `heddle push {track_name}` after fixing the remote."
),
}
}
}
impl std::fmt::Display for PushFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Preflight(blocker) => write!(f, "{blocker}"),
Self::NamedThreadTipMismatch {
thread,
tip_short,
current_short,
} => write!(
f,
"thread '{thread}' already exists at {tip_short} but the current checkout is {current_short}; refusing to overwrite it"
),
Self::RemoteFailed { track_name, error } => {
write!(f, "Push failed for {track_name}: {error}")
}
}
}
}
impl std::error::Error for PushFailure {}
impl PullFailure {
pub fn advice_kind(&self) -> &'static str {
match self {
Self::Preflight(RemotePreflightBlocker::MissingRemote) => {
remote_advice_kind::REMOTE_NOT_CONFIGURED
}
Self::Preflight(RemotePreflightBlocker::TransportMismatch) => {
remote_advice_kind::REMOTE_TRANSPORT_MISMATCH
}
Self::Preflight(RemotePreflightBlocker::GitOverlayThreadMismatch { .. }) => {
remote_advice_kind::GIT_OVERLAY_THREAD_MISMATCH
}
Self::LocalLazyUnsupported { .. } => remote_advice_kind::LOCAL_LAZY_PULL_UNSUPPORTED,
Self::RemoteFailed { .. } => remote_advice_kind::REMOTE_PULL_FAILED,
}
}
pub fn primary_command(&self) -> String {
match self {
Self::Preflight(RemotePreflightBlocker::MissingRemote) => {
"heddle remote add <name> <url>".to_string()
}
Self::Preflight(RemotePreflightBlocker::TransportMismatch) => {
"heddle clone <remote> <fresh-path>".to_string()
}
Self::Preflight(RemotePreflightBlocker::GitOverlayThreadMismatch {
requested, ..
}) => format!("heddle thread switch {requested}"),
Self::LocalLazyUnsupported { source_path } => {
format!("heddle pull {source_path}")
}
Self::RemoteFailed {
remote_thread,
local_thread,
..
} => {
if let Some(local) = local_thread {
format!("heddle pull {remote_thread} {local}")
} else {
format!("heddle pull {remote_thread}")
}
}
}
}
pub fn recovery_hint(&self) -> String {
match self {
Self::Preflight(RemotePreflightBlocker::MissingRemote) => {
"Add a remote with `heddle remote add <name> <url>`, inspect remotes with `heddle remote list`, or choose one with `heddle remote set-default <name>`. Ad-hoc targets are supported without configuration: `heddle pull <remote>` accepts a remote name, URL, local path, or hosted address positionally.".to_string()
}
Self::Preflight(RemotePreflightBlocker::TransportMismatch) => {
"Use a Heddle-native remote here, or clone/adopt that Git remote in a Git-overlay checkout.".to_string()
}
Self::Preflight(RemotePreflightBlocker::GitOverlayThreadMismatch { .. }) => {
"Switch to the attached thread, or omit an explicit mismatched thread name.".to_string()
}
Self::LocalLazyUnsupported { source_path } => format!(
"Run `heddle pull {source_path}` without `--lazy`, or configure a hosted remote and retry lazy pull there."
),
Self::RemoteFailed {
remote_thread,
local_thread,
..
} => {
let cmd = if let Some(local) = local_thread {
format!("heddle pull {remote_thread} {local}")
} else {
format!("heddle pull {remote_thread}")
};
format!("Inspect `heddle verify`, then retry with `{cmd}` after fixing the remote.")
}
}
}
}
impl std::fmt::Display for PullFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Preflight(blocker) => write!(f, "{blocker}"),
Self::LocalLazyUnsupported { .. } => write!(
f,
"Refusing lazy pull from local remote: lazy materialization requires a hosted or network remote"
),
Self::RemoteFailed {
remote_thread,
error,
..
} => write!(f, "Pull failed from {remote_thread}: {error}"),
}
}
}
impl std::error::Error for PullFailure {}
impl RemotePreflightBlocker {
pub fn advice_kind(&self) -> &'static str {
match self {
Self::MissingRemote => remote_advice_kind::REMOTE_NOT_CONFIGURED,
Self::TransportMismatch => remote_advice_kind::REMOTE_TRANSPORT_MISMATCH,
Self::GitOverlayThreadMismatch { .. } => {
remote_advice_kind::GIT_OVERLAY_THREAD_MISMATCH
}
}
}
}
pub fn refuse_named_thread_tip_overwrite(
force: bool,
named_thread: Option<&str>,
existing_tip_differs: bool,
) -> bool {
named_thread.is_some() && !force && existing_tip_differs
}
pub fn named_thread_tip_mismatch_failure(
thread: &str,
tip_short: impl Into<String>,
current_short: impl Into<String>,
) -> PushFailure {
PushFailure::NamedThreadTipMismatch {
thread: thread.to_string(),
tip_short: tip_short.into(),
current_short: current_short.into(),
}
}
pub fn first_multi_thread_push_failure(failures: &[(String, String)]) -> Option<PushFailure> {
failures
.first()
.map(|(name, err)| remote_push_failure(name, Some(err.as_str())))
}
pub const UNKNOWN_TRANSPORT_ERROR: &str = "Unknown error";
pub fn transport_error_message(error: Option<&str>) -> String {
match error.map(str::trim).filter(|s| !s.is_empty()) {
Some(s) => s.to_string(),
None => UNKNOWN_TRANSPORT_ERROR.to_string(),
}
}
pub fn remote_push_failure(track_name: &str, error: Option<&str>) -> PushFailure {
PushFailure::RemoteFailed {
track_name: track_name.to_string(),
error: transport_error_message(error),
}
}
pub fn remote_pull_failure(
remote_thread: &str,
local_thread: Option<&str>,
error: Option<&str>,
) -> PullFailure {
PullFailure::RemoteFailed {
remote_thread: remote_thread.to_string(),
local_thread: local_thread.map(str::to_string),
error: transport_error_message(error),
}
}
pub fn multi_thread_failed_names(failures: &[(String, String)]) -> Vec<String> {
failures.iter().map(|(thread, _)| thread.clone()).collect()
}
pub fn multi_thread_reported_refs(pushed_threads: &[String]) -> Vec<String> {
let mut refs = pushed_threads.to_vec();
refs.sort();
refs
}
pub fn multi_thread_push_execution_facts(
pushed_threads: Vec<String>,
failures: &[(String, String)],
objects: usize,
) -> PushExecutionFacts {
PushExecutionFacts::HeddleAllThreads {
pushed_threads,
failed_threads: multi_thread_failed_names(failures),
objects,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostedPushResultFields {
pub success: bool,
pub new_state: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostedPullResultFields {
pub success: bool,
pub final_state: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalTransferSummary {
pub state: Option<String>,
pub objects: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HostedPushResult {
Success { state: Option<String> },
Failed(PushFailure),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HostedPullResult {
Success { final_state: Option<String> },
Failed(PullFailure),
}
pub fn parse_hosted_push_result(
track_name: &str,
fields: &HostedPushResultFields,
) -> HostedPushResult {
if fields.success {
HostedPushResult::Success {
state: fields.new_state.clone(),
}
} else {
HostedPushResult::Failed(remote_push_failure(track_name, fields.error.as_deref()))
}
}
pub fn parse_hosted_pull_result(
remote_thread: &str,
local_thread: Option<&str>,
fields: &HostedPullResultFields,
) -> HostedPullResult {
if fields.success {
HostedPullResult::Success {
final_state: fields.final_state.clone(),
}
} else {
HostedPullResult::Failed(remote_pull_failure(
remote_thread,
local_thread,
fields.error.as_deref(),
))
}
}
pub fn heddle_single_push_execution_facts(
state: Option<String>,
objects: Option<usize>,
) -> PushExecutionFacts {
PushExecutionFacts::HeddleSingle { state, objects }
}
pub fn heddle_single_push_execution_facts_from_local(
summary: &LocalTransferSummary,
) -> PushExecutionFacts {
heddle_single_push_execution_facts(summary.state.clone(), summary.objects)
}
pub fn heddle_single_push_execution_facts_from_hosted(
fields: &HostedPushResultFields,
) -> PushExecutionFacts {
heddle_single_push_execution_facts(fields.new_state.clone(), None)
}
pub fn git_overlay_push_execution_facts(
remote_name: String,
current_thread: Option<String>,
refs_written: Vec<String>,
tracking: Option<GitOverlayPushTracking>,
) -> PushExecutionFacts {
PushExecutionFacts::GitOverlayRefs {
remote_name,
current_thread,
refs_written,
tracking,
}
}
pub fn heddle_pull_execution_facts(
changed: bool,
remote: String,
thread: String,
state: Option<String>,
objects: Option<usize>,
) -> PullExecutionFacts {
PullExecutionFacts::Heddle {
changed,
remote,
thread,
state,
objects,
}
}
pub fn heddle_pull_execution_facts_from_hosted(
changed: bool,
remote: String,
thread: String,
fields: &HostedPullResultFields,
) -> PullExecutionFacts {
heddle_pull_execution_facts(changed, remote, thread, fields.final_state.clone(), None)
}
pub fn heddle_pull_execution_facts_from_local(
changed: bool,
remote: String,
thread: String,
summary: &LocalTransferSummary,
) -> PullExecutionFacts {
heddle_pull_execution_facts(
changed,
remote,
thread,
summary.state.clone(),
summary.objects,
)
}
#[allow(clippy::too_many_arguments)]
pub fn git_overlay_pull_execution_facts(
remote: String,
branch: Option<String>,
old_git_head: Option<String>,
new_git_head: Option<String>,
old_state: Option<String>,
new_state: Option<String>,
changed: bool,
states_created: usize,
commits_seen: usize,
materialized_checkout: bool,
changed_paths: Vec<String>,
) -> PullExecutionFacts {
PullExecutionFacts::GitOverlay {
remote,
branch,
old_git_head,
new_git_head,
old_state,
new_state,
changed,
states_created,
commits_seen,
materialized_checkout,
changed_paths,
}
}
pub fn pull_tip_changed(pre_target: Option<&str>, final_state: Option<&str>) -> bool {
match final_state {
Some(state) => pre_target != Some(state),
None => false,
}
}
pub fn local_pull_changed(
pre_target: Option<&str>,
final_state: &str,
objects_copied: usize,
) -> bool {
pre_target != Some(final_state) || objects_copied > 0
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MultiRefPushProgress {
Begin {
target: String,
},
ThreadSucceeded {
thread: String,
state_short: Option<String>,
objects: Option<usize>,
remote_state: Option<String>,
},
ThreadFailed { thread: String, error: String },
}
pub fn multi_ref_push_begin(target: impl Into<String>) -> MultiRefPushProgress {
MultiRefPushProgress::Begin {
target: target.into(),
}
}
pub fn multi_ref_thread_succeeded_local(
thread: impl Into<String>,
state_short: Option<String>,
objects: Option<usize>,
) -> MultiRefPushProgress {
MultiRefPushProgress::ThreadSucceeded {
thread: thread.into(),
state_short,
objects,
remote_state: None,
}
}
pub fn multi_ref_thread_succeeded_hosted(
thread: impl Into<String>,
remote_state: Option<String>,
) -> MultiRefPushProgress {
MultiRefPushProgress::ThreadSucceeded {
thread: thread.into(),
state_short: None,
objects: None,
remote_state,
}
}
pub fn multi_ref_thread_failed(
thread: impl Into<String>,
error: Option<&str>,
) -> MultiRefPushProgress {
MultiRefPushProgress::ThreadFailed {
thread: thread.into(),
error: transport_error_message(error),
}
}
pub fn multi_ref_progress_from_hosted_thread(
thread: &str,
fields: &HostedPushResultFields,
) -> MultiRefPushProgress {
if fields.success {
multi_ref_thread_succeeded_hosted(thread, fields.new_state.clone())
} else {
multi_ref_thread_failed(thread, fields.error.as_deref())
}
}
pub fn format_multi_ref_push_progress(event: &MultiRefPushProgress) -> String {
match event {
MultiRefPushProgress::Begin { target } => {
format!("pushing all threads to {target}")
}
MultiRefPushProgress::ThreadSucceeded {
thread,
state_short: Some(state),
objects: Some(n),
..
} => {
let unit = if *n == 1 { "object" } else { "objects" };
format!("pushed {state} to {thread} ({n} {unit})")
}
MultiRefPushProgress::ThreadSucceeded {
thread,
state_short: Some(state),
objects: None,
..
} => format!("pushed {state} to {thread}"),
MultiRefPushProgress::ThreadSucceeded {
thread,
state_short: None,
objects: Some(n),
..
} => {
let unit = if *n == 1 { "object" } else { "objects" };
format!("pushed to {thread} ({n} {unit})")
}
MultiRefPushProgress::ThreadSucceeded {
thread,
remote_state: Some(state),
..
} => format!("pushed to {thread} (remote state {state})"),
MultiRefPushProgress::ThreadSucceeded { thread, .. } => {
format!("pushed to {thread}")
}
MultiRefPushProgress::ThreadFailed { thread, error } => {
format!("failed to push {thread}: {error}")
}
}
}
pub fn format_ref_list(refs: &[String]) -> String {
refs.join(", ")
}
pub fn format_multi_thread_refs_detail(pushed_threads: &[String]) -> Option<String> {
if pushed_threads.is_empty() {
return None;
}
let sorted = multi_thread_reported_refs(pushed_threads);
Some(format!("refs: {}", format_ref_list(&sorted)))
}
pub fn format_pushing_to(target: &str) -> String {
format!("pushing to {target}")
}
pub fn format_pulling_from(source: &str) -> String {
format!("pulling from {source}")
}
pub fn format_connected_to(addr: &str) -> String {
format!("connected to {addr}")
}
pub fn format_remote_state_detail(state: &str) -> String {
format!("remote state: {state}")
}
pub fn format_mirror_success_text(remote: &str) -> String {
format!("mirrored to {remote}")
}
pub fn format_mirror_failure_text(remote: &str, error: &str) -> String {
format!("mirror push to {remote} failed (primary push still succeeded): {error}")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PushOutcomeText {
pub headline: String,
pub detail_lines: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PullOutcomeText {
pub headline: String,
pub detail_lines: Vec<String>,
}
pub fn git_overlay_push_scope_description(all_threads: bool) -> &'static str {
if all_threads {
"all threads + Git tags + refs/notes/heddle"
} else {
"branch + refs/notes/heddle; tags skipped"
}
}
pub const ALL_THREADS_MIRROR_COVERS_NOTE: &str =
"Git Projection push covers all threads (every ref shipped in one transfer)";
pub fn all_threads_mirror_coverage_note(all_threads: bool) -> Option<&'static str> {
all_threads.then_some(ALL_THREADS_MIRROR_COVERS_NOTE)
}
pub fn format_push_outcome_text(
outcome: &PushOutcome,
track_name: Option<&str>,
) -> PushOutcomeText {
let headline = match outcome.transport {
"git" => {
let remote = outcome.remote.as_deref().unwrap_or("remote");
let all_threads = outcome.push_scope == Some("all_threads");
let subject = if all_threads {
"all threads".to_string()
} else {
outcome
.thread
.as_deref()
.map(|t| format!("thread {t}"))
.unwrap_or_else(|| "current thread".to_string())
};
format!(
"pushed {subject} to {remote} ({})",
git_overlay_push_scope_description(all_threads)
)
}
"heddle" if outcome.push_scope == Some("all_threads") => summarize_push_outcome(outcome),
"heddle" => {
let track = track_name.or(outcome.thread.as_deref()).unwrap_or("thread");
match (&outcome.state, outcome.objects) {
(Some(state), Some(objects)) => {
let unit = if objects == 1 { "object" } else { "objects" };
format!("pushed {state} to {track} ({objects} {unit})")
}
(Some(state), None) => format!("pushed to {track} (state {state})"),
(None, Some(objects)) => {
let unit = if objects == 1 { "object" } else { "objects" };
format!("pushed to {track} ({objects} {unit})")
}
(None, None) => format!("pushed to {track}"),
}
}
_ => summarize_push_outcome(outcome),
};
let mut detail_lines = Vec::new();
if let Some(warning) = outcome.force_discard_warning {
detail_lines.push(format!("Force: {warning}."));
}
if outcome.git_notes_ref.is_some() {
detail_lines.push(format!(
"Git interop: published {GIT_NOTES_REF}; ordinary `git log --all` may show Heddle metadata commits."
));
}
if let Some(configured) = &outcome.git_remote_configured {
detail_lines.push(format!(
"Git tracking: configured remote {} -> {} for future fetch/push.",
configured.name, configured.url
));
}
if let Some(upstream) = &outcome.git_upstream_configured {
detail_lines.push(format!(
"Git tracking: branch {} tracks {}/{}.",
upstream.branch, upstream.remote, upstream.branch
));
}
PushOutcomeText {
headline,
detail_lines,
}
}
pub fn format_pull_outcome_text(outcome: &PullOutcome, max_paths: usize) -> PullOutcomeText {
let headline = if !outcome.changed {
format!(
"already up to date with {}; repository verification checked below",
outcome.remote
)
} else if outcome.transport == "git" {
format!("pulled from {}", outcome.remote)
} else if let (Some(state), Some(objects)) = (&outcome.state, outcome.objects) {
let unit = if objects == 1 { "object" } else { "objects" };
let thread = outcome.thread.as_deref().unwrap_or("thread");
format!("pulled {state} from {thread} ({objects} {unit})")
} else if outcome.transport == "heddle" {
format!(
"pulled from {}",
outcome.thread.as_deref().unwrap_or(outcome.remote.as_str())
)
} else {
summarize_pull_outcome(outcome)
};
let mut detail_lines = Vec::new();
if outcome.transport == "git" {
if let Some(branch) = &outcome.branch {
if outcome.changed {
detail_lines.push(format!("Branch: {branch}"));
} else if let Some(head) = &outcome.new_git_head {
let short: String = head.chars().take(12).collect();
detail_lines.push(format!("Branch: {branch} at {short}"));
}
}
match (&outcome.old_git_head, &outcome.new_git_head) {
(Some(old), Some(new)) if old != new => {
let old_s: String = old.chars().take(12).collect();
let new_s: String = new.chars().take(12).collect();
detail_lines.push(format!("Git: {old_s} -> {new_s}"));
}
(Some(head), Some(_)) if outcome.changed => {
let short: String = head.chars().take(12).collect();
detail_lines.push(format!("Git: {short}"));
}
_ => {}
}
if let Some(states) = outcome.states_created {
let unit = if states == 1 {
"new state"
} else {
"new states"
};
detail_lines.push(format!("Imported: {states} {unit}"));
}
if let Some(commits) = outcome.commits_seen {
let unit = if commits == 1 {
"Git commit object"
} else {
"Git commit objects"
};
detail_lines.push(format!(
"Scanned: {commits} {unit} across branches + refs/notes/heddle"
));
}
if outcome.materialized_checkout == Some(true) {
detail_lines.push("Worktree: materialized checkout".to_string());
}
if outcome.changed
&& let Some(paths) = &outcome.changed_paths
{
detail_lines.push(format!("Changed paths: {}", paths.len()));
for path in paths.iter().take(max_paths) {
detail_lines.push(format!(" - {path}"));
}
if paths.len() > max_paths {
detail_lines.push(format!(" - ... {} more", paths.len() - max_paths));
}
}
} else if outcome.changed
&& let Some(state) = &outcome.state
&& outcome.objects.is_none()
{
detail_lines.push(format!("state: {state}"));
}
PullOutcomeText {
headline,
detail_lines,
}
}
pub fn pull_should_materialize(will_materialize: bool, lazy: bool) -> bool {
will_materialize && !lazy
}
pub fn merged_remote_items(repo: &Repository) -> Result<BTreeMap<String, (String, String)>> {
if repo.capability() == RepositoryCapability::GitOverlay {
return Ok(git_overlay_config_remotes(repo)
.into_iter()
.map(|(name, url)| (name, (url, "git-overlay".to_string())))
.collect());
}
let cfg = RemoteConfig::open(repo).map_err(anyhow::Error::new)?;
let 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();
Ok(items)
}
pub 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 plain_git_default_remote_name(root: &Path, items: &BTreeMap<String, String>) -> Option<String> {
let git = SleyRepository::discover(root).ok()?;
let config = git.config_snapshot().ok()?;
let branch = git.head().ok()?.symbolic_target.and_then(|name| {
name.as_str()
.strip_prefix("refs/heads/")
.map(str::to_string)
});
branch
.as_deref()
.and_then(|branch| config.get("branch", Some(branch), "remote"))
.or_else(|| config.get("remote", None, "pushDefault"))
.map(str::to_string)
.filter(|name| items.contains_key(name))
.or_else(|| default_remote_from_items(items))
}
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)
&& git_remotes.contains_key(&upstream_remote)
{
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_overlay_default_push_remote_name(repo: &Repository) -> Option<String> {
let remotes = git_overlay_config_remotes(repo);
let git = SleyRepository::discover(repo.root()).ok()?;
let config = git.config_snapshot().ok()?;
let branch = repo.git_overlay_current_branch().ok().flatten();
branch
.as_deref()
.and_then(|branch| config.get("branch", Some(branch), "pushRemote"))
.or_else(|| config.get("remote", None, "pushDefault"))
.or_else(|| {
branch
.as_deref()
.and_then(|branch| config.get("branch", Some(branch), "remote"))
})
.map(str::to_string)
.filter(|name| remotes.contains_key(name))
.or_else(|| default_remote_from_items(&remotes))
}
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 git_overlay_config_remotes(repo: &Repository) -> BTreeMap<String, String> {
let Some(ctx) = GitConfigContext::discover(repo.root()) else {
return BTreeMap::new();
};
ctx.remotes(ctx.layered_paths())
}
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<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()
}
pub fn looks_like_git_remote_url(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
lower.starts_with("http://")
|| lower.starts_with("https://")
|| lower.starts_with("ssh://")
|| lower.starts_with("git://")
|| lower.ends_with(".git")
|| (value.contains('@') && value.contains(':'))
}
pub fn looks_like_remote_location(value: &str) -> bool {
value.starts_with('/')
|| value.starts_with("./")
|| value.starts_with("../")
|| value.starts_with("~/")
|| value.contains("://")
|| value.contains('\\')
}
pub fn remote_urls_match(left: &str, right: &str) -> bool {
if left == right {
return true;
}
let left_path = Path::new(left);
let right_path = Path::new(right);
match (left_path.canonicalize(), right_path.canonicalize()) {
(Ok(left), Ok(right)) => left == right,
_ => false,
}
}
pub fn message_indicates_already_exists(message: &str) -> bool {
message.to_ascii_lowercase().contains("already exists")
}
pub fn hosted_path_contains_internal_user_namespace(value: &str) -> bool {
value.contains("__users/")
}
pub fn redact_internal_hosted_paths(message: &str) -> String {
message
.split_whitespace()
.map(|part| {
if hosted_path_contains_internal_user_namespace(part) {
"[user namespace]"
} else {
part
}
})
.collect::<Vec<_>>()
.join(" ")
}
pub fn hosted_spool_display_path(
namespace_slug: &str,
spool_slug: &str,
full_path: &str,
) -> String {
if hosted_path_contains_internal_user_namespace(full_path) && !namespace_slug.is_empty() {
format!("{namespace_slug}/{spool_slug}")
} else {
full_path.to_string()
}
}
pub fn is_native_transport_mismatch(
capability: RepositoryCapability,
remote_is_git_local_or_url: bool,
) -> bool {
capability != RepositoryCapability::GitOverlay && remote_is_git_local_or_url
}
#[derive(Debug, Clone, thiserror::Error)]
#[error("Remote '{name}' is defined in an included Git config that heddle won't edit: {path}")]
pub struct IncludedGitRemoteConfigError {
pub name: String,
pub path: PathBuf,
}
impl IncludedGitRemoteConfigError {
fn new(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
Self {
name: name.into(),
path: path.into(),
}
}
}
#[derive(Debug, Clone)]
pub struct GitConfigContext {
git_dir: PathBuf,
common_dir: PathBuf,
branch: Option<String>,
}
impl GitConfigContext {
pub 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)),
})
}
pub fn common_dir(&self) -> &Path {
&self.common_dir
}
pub fn layered_paths(&self) -> Vec<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)
}
pub fn write_file_for(
&self,
name: &str,
) -> std::result::Result<PathBuf, IncludedGitRemoteConfigError> {
match self.defining_files_for(name).into_iter().next() {
Some(path) => {
if !self.owns_config_file(&path) {
return Err(IncludedGitRemoteConfigError::new(name, path));
}
Ok(path)
}
None => Ok(self.common_dir.join("config")),
}
}
pub fn remove_files_for(
&self,
name: &str,
) -> std::result::Result<Vec<PathBuf>, IncludedGitRemoteConfigError> {
let files = self.defining_files_for(name);
for path in &files {
if !self.owns_config_file(path) {
return Err(IncludedGitRemoteConfigError::new(name, path.clone()));
}
}
Ok(files)
}
pub fn defining_files_for(&self, name: &str) -> Vec<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
}
pub 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)
})
}
pub fn remotes(&self, paths: Vec<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())
}
pub fn included_config_error(err: IncludedGitRemoteConfigError) -> anyhow::Error {
anyhow!(err)
}
#[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 list_plain_git_marks_origin_default() {
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\n\
[remote \"upstream\"]\n\turl = https://example.com/up\n",
)
.unwrap();
let report = list_plain_git_remotes(tmp.path());
assert_eq!(report.output_kind, "remote_list");
assert_eq!(report.remotes.len(), 2);
let origin = report.remotes.iter().find(|r| r.name == "origin").unwrap();
assert!(origin.is_default);
assert_eq!(origin.source, "git");
let upstream = report
.remotes
.iter()
.find(|r| r.name == "upstream")
.unwrap();
assert!(!upstream.is_default);
}
#[test]
fn write_file_for_rejects_external_include() {
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 defining_files_follow_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 \"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"));
}
#[test]
fn git_overlay_all_threads_hosted_push_is_single_mirror() {
assert!(
all_threads_uses_single_mirror_push(RepositoryCapability::GitOverlay),
"git-overlay --all-threads must collapse to one mirror push",
);
assert!(
!all_threads_uses_single_mirror_push(RepositoryCapability::NativeHeddle),
"native --all-threads must keep the per-thread fan-out (#838)",
);
}
#[test]
fn plan_hosted_push_routes_by_capability_and_all_threads() {
assert_eq!(
plan_hosted_push(RepositoryCapability::NativeHeddle, true),
HostedPushPlan::NativePerThreadFanout,
);
assert_eq!(
plan_hosted_push(RepositoryCapability::GitOverlay, true),
HostedPushPlan::GitOverlayMirror,
);
assert_eq!(
plan_hosted_push(RepositoryCapability::GitOverlay, false),
HostedPushPlan::GitOverlayMirror,
);
assert_eq!(
plan_hosted_push(RepositoryCapability::NativeHeddle, false),
HostedPushPlan::NativeSingleThread,
);
}
#[test]
fn uses_git_overlay_mirror_rpc_only_for_overlay() {
assert!(uses_git_overlay_mirror_rpc(
RepositoryCapability::GitOverlay
));
assert!(!uses_git_overlay_mirror_rpc(
RepositoryCapability::NativeHeddle
));
}
#[test]
fn uses_local_git_overlay_transport_follows_resolved_remote() {
assert!(uses_local_git_overlay_transport(
RepositoryCapability::GitOverlay,
false,
));
assert!(!uses_local_git_overlay_transport(
RepositoryCapability::GitOverlay,
true,
));
assert!(!uses_local_git_overlay_transport(
RepositoryCapability::NativeHeddle,
false,
));
}
#[test]
fn overlay_push_remote_uses_git_precedence() {
let tmp = tempfile::TempDir::new().unwrap();
init_git(tmp.path());
fs::write(tmp.path().join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
fs::write(
tmp.path().join(".git/config"),
"[remote \"origin\"]\n\turl = https://example.com/origin\n\
[remote \"upstream\"]\n\turl = https://example.com/upstream\n\
[remote \"publish\"]\n\turl = https://example.com/publish\n\
[remote]\n\tpushDefault = publish\n\
[branch \"main\"]\n\tremote = origin\n\tpushRemote = upstream\n",
)
.unwrap();
let repo = Repository::init_git_overlay_sidecar(tmp.path()).unwrap();
assert_eq!(
resolve_default_push_remote_name(&repo, None).unwrap(),
"upstream"
);
let config = fs::read_to_string(tmp.path().join(".git/config")).unwrap();
fs::write(
tmp.path().join(".git/config"),
config.replace("\tpushRemote = upstream\n", ""),
)
.unwrap();
assert_eq!(
resolve_default_push_remote_name(&repo, None).unwrap(),
"publish"
);
}
#[test]
fn overlay_remote_resolution_does_not_invent_origin() {
let tmp = tempfile::TempDir::new().unwrap();
init_git(tmp.path());
let repo = Repository::init_git_overlay_sidecar(tmp.path()).unwrap();
assert!(resolve_default_remote_name(&repo, None).is_err());
assert!(resolve_default_push_remote_name(&repo, None).is_err());
}
#[test]
fn default_push_thread_prefers_explicit_then_attached_then_main() {
let attached = Head::Attached {
thread: objects::object::ThreadName::new("feature"),
};
let detached = Head::Detached {
state: objects::object::StateId::from_bytes([75; 32]),
};
assert_eq!(
default_push_thread_name(Some("release"), &attached),
"release"
);
assert_eq!(default_push_thread_name(None, &attached), "feature");
assert_eq!(default_push_thread_name(None, &detached), "main");
}
#[test]
fn default_pull_thread_uses_current_git_overlay_thread() {
let head = Head::Attached {
thread: objects::object::ThreadName::new("master"),
};
assert_eq!(
default_pull_thread_name(None, RepositoryCapability::GitOverlay, &head),
"master"
);
}
#[test]
fn default_pull_thread_keeps_native_main_default() {
let head = Head::Attached {
thread: objects::object::ThreadName::new("feature"),
};
assert_eq!(
default_pull_thread_name(None, RepositoryCapability::NativeHeddle, &head),
"main"
);
}
#[test]
fn default_pull_thread_honors_explicit_thread() {
let head = Head::Attached {
thread: objects::object::ThreadName::new("master"),
};
assert_eq!(
default_pull_thread_name(Some("release"), RepositoryCapability::GitOverlay, &head),
"release"
);
}
#[test]
fn git_overlay_current_thread_push_refuses_mismatched_thread() {
assert!(git_overlay_current_thread_push_ok(
false,
None,
Some("main")
));
assert!(git_overlay_current_thread_push_ok(
false,
Some("main"),
Some("main")
));
assert!(!git_overlay_current_thread_push_ok(
false,
Some("feature"),
Some("main")
));
assert!(!git_overlay_current_thread_push_ok(
false,
Some("feature"),
None
));
assert!(git_overlay_current_thread_push_ok(
true,
Some("feature"),
Some("main")
));
}
fn attached_head(name: &str) -> Head {
Head::Attached {
thread: objects::object::ThreadName::new(name),
}
}
fn detached_head() -> Head {
Head::Detached {
state: objects::object::StateId::from_bytes([76; 32]),
}
}
fn base_push_request() -> PushPlanRequest {
PushPlanRequest {
capability: RepositoryCapability::NativeHeddle,
uses_hosted_network: false,
remote: Some("origin".to_string()),
has_default_remote: true,
thread: None,
all_threads: false,
force: false,
head: attached_head("main"),
native_local_heddle_target: false,
transport_mismatch: false,
}
}
fn base_pull_request() -> PullPlanRequest {
PullPlanRequest {
capability: RepositoryCapability::NativeHeddle,
uses_hosted_network: false,
remote: Some("origin".to_string()),
has_default_remote: true,
thread: None,
local_thread: None,
head: attached_head("main"),
transport_mismatch: false,
lazy: false,
}
}
#[test]
fn remote_missing_blocker_table() {
assert_eq!(
remote_missing_blocker(None, false),
Some(RemotePreflightBlocker::MissingRemote)
);
assert_eq!(remote_missing_blocker(None, true), None);
assert_eq!(remote_missing_blocker(Some("origin"), false), None);
assert_eq!(remote_missing_blocker(Some("origin"), true), None);
}
#[test]
fn transport_mismatch_blocker_table() {
assert_eq!(
transport_mismatch_blocker(false, true),
Some(RemotePreflightBlocker::TransportMismatch)
);
assert_eq!(transport_mismatch_blocker(true, true), None);
assert_eq!(transport_mismatch_blocker(false, false), None);
assert_eq!(transport_mismatch_blocker(true, false), None);
}
#[test]
fn pull_clean_worktree_policy_table() {
let cases = [
(true, true, true),
(true, false, true),
(false, true, true),
(false, false, false),
];
for (overlay, materialize, expected) in cases {
assert_eq!(
pull_requires_clean_worktree(overlay, materialize),
expected,
"overlay={overlay} materialize={materialize}"
);
}
}
#[test]
fn pull_will_materialize_table() {
let attached = attached_head("feature");
let detached = detached_head();
assert!(pull_will_materialize(None, "feature", &attached));
assert!(!pull_will_materialize(None, "main", &attached));
assert!(pull_will_materialize(Some("feature"), "main", &attached));
assert!(!pull_will_materialize(Some("other"), "feature", &attached));
assert!(pull_will_materialize(None, "main", &detached));
assert!(!pull_will_materialize(Some("feature"), "main", &detached));
}
#[test]
fn plan_push_missing_remote() {
let mut req = base_push_request();
req.remote = None;
req.has_default_remote = false;
assert_eq!(plan_push(&req), Err(RemotePreflightBlocker::MissingRemote));
}
#[test]
fn plan_push_transport_mismatch_on_native_path() {
let mut req = base_push_request();
req.transport_mismatch = true;
assert_eq!(
plan_push(&req),
Err(RemotePreflightBlocker::TransportMismatch)
);
}
#[test]
fn plan_push_ignores_transport_mismatch_on_local_overlay() {
let mut req = base_push_request();
req.capability = RepositoryCapability::GitOverlay;
req.transport_mismatch = true;
let plan = plan_push(&req).expect("overlay path skips mismatch");
assert!(plan.uses_local_git_overlay);
assert!(matches!(plan.path, PushPath::LocalGitOverlayRefs { .. }));
}
#[test]
fn plan_push_git_overlay_thread_mismatch() {
let mut req = base_push_request();
req.capability = RepositoryCapability::GitOverlay;
req.thread = Some("feature".to_string());
req.head = attached_head("main");
assert_eq!(
plan_push(&req),
Err(RemotePreflightBlocker::GitOverlayThreadMismatch {
requested: "feature".to_string(),
attached: Some("main".to_string()),
})
);
}
#[test]
fn plan_push_native_local_heddle_skips_thread_mismatch() {
let mut req = base_push_request();
req.capability = RepositoryCapability::GitOverlay;
req.thread = Some("feature".to_string());
req.head = attached_head("main");
req.native_local_heddle_target = true;
let plan = plan_push(&req).expect("native local skips overlay thread gate");
assert!(matches!(
plan.path,
PushPath::LocalNativeHeddle { all_threads: false }
));
assert_eq!(plan.track_name, "feature");
}
#[test]
fn plan_push_hosted_and_fanout_selection_table() {
let cases = [
(
RepositoryCapability::NativeHeddle,
true,
HostedPushPlan::NativePerThreadFanout,
true,
false,
),
(
RepositoryCapability::GitOverlay,
true,
HostedPushPlan::GitOverlayMirror,
false,
true,
),
(
RepositoryCapability::GitOverlay,
false,
HostedPushPlan::GitOverlayMirror,
false,
true,
),
(
RepositoryCapability::NativeHeddle,
false,
HostedPushPlan::NativeSingleThread,
false,
false,
),
];
for (capability, all_threads, hosted, fanout, mirror) in cases {
let mut req = base_push_request();
req.capability = capability;
req.all_threads = all_threads;
req.uses_hosted_network = capability == RepositoryCapability::GitOverlay;
let plan = plan_push(&req).expect("plan");
assert_eq!(plan.hosted, hosted, "capability={capability:?}");
assert_eq!(plan.native_all_threads_fanout, fanout);
assert_eq!(plan.uses_git_overlay_mirror_rpc, mirror);
assert!(matches!(
plan.path,
PushPath::NativeRemote {
hosted: h,
uses_mirror_rpc: m,
native_all_threads_fanout: f,
} if h == hosted && m == mirror && f == fanout
));
}
}
#[test]
fn plan_push_local_overlay_refs_path() {
let mut req = base_push_request();
req.capability = RepositoryCapability::GitOverlay;
req.all_threads = true;
let plan = plan_push(&req).unwrap();
assert!(plan.uses_local_git_overlay);
assert_eq!(
plan.path,
PushPath::LocalGitOverlayRefs { all_threads: true }
);
assert_eq!(plan.track_name, "main");
}
#[test]
fn plan_push_track_name_from_head() {
let mut req = base_push_request();
req.remote = Some("origin".into());
req.head = attached_head("feature");
let plan = plan_push(&req).unwrap();
assert_eq!(plan.track_name, "feature");
req.thread = Some("release".into());
let plan = plan_push(&req).unwrap();
assert_eq!(plan.track_name, "release");
}
#[test]
fn plan_pull_missing_remote() {
let mut req = base_pull_request();
req.remote = None;
req.has_default_remote = false;
assert_eq!(plan_pull(&req), Err(RemotePreflightBlocker::MissingRemote));
}
#[test]
fn plan_pull_transport_mismatch() {
let mut req = base_pull_request();
req.transport_mismatch = true;
assert_eq!(
plan_pull(&req),
Err(RemotePreflightBlocker::TransportMismatch)
);
}
#[test]
fn plan_pull_local_overlay_requires_clean() {
let mut req = base_pull_request();
req.capability = RepositoryCapability::GitOverlay;
req.local_thread = Some("other".into());
let plan = plan_pull(&req).unwrap();
assert!(plan.uses_local_git_overlay);
assert!(!plan.will_materialize);
assert!(plan.requires_clean_worktree);
assert_eq!(plan.remote_thread, "main");
}
#[test]
fn plan_pull_native_materialize_policy() {
let mut req = base_pull_request();
req.head = attached_head("feature");
let plan = plan_pull(&req).unwrap();
assert!(!plan.uses_local_git_overlay);
assert!(!plan.will_materialize);
assert!(!plan.requires_clean_worktree);
assert_eq!(plan.remote_thread, "main");
req.thread = Some("feature".into());
let plan = plan_pull(&req).unwrap();
assert!(plan.will_materialize);
assert!(plan.requires_clean_worktree);
req.local_thread = Some("scratch".into());
let plan = plan_pull(&req).unwrap();
assert!(!plan.will_materialize);
assert!(!plan.requires_clean_worktree);
}
#[test]
fn plan_pull_thread_defaults_table() {
let attached = attached_head("master");
let mut req = base_pull_request();
req.capability = RepositoryCapability::GitOverlay;
req.head = attached.clone();
let plan = plan_pull(&req).unwrap();
assert_eq!(plan.remote_thread, "master");
req.thread = Some("release".into());
let plan = plan_pull(&req).unwrap();
assert_eq!(plan.remote_thread, "release");
req.capability = RepositoryCapability::NativeHeddle;
req.thread = None;
req.head = attached_head("feature");
let plan = plan_pull(&req).unwrap();
assert_eq!(plan.remote_thread, "main");
}
#[test]
fn build_git_overlay_push_outcome_matches_success_json_fields() {
let mut req = base_push_request();
req.capability = RepositoryCapability::GitOverlay;
req.force = true;
req.all_threads = false;
let plan = plan_push(&req).unwrap();
let outcome = build_push_outcome(
&plan,
PushExecutionFacts::GitOverlayRefs {
remote_name: "origin".into(),
current_thread: Some("main".into()),
refs_written: vec!["refs/heads/main".into(), "refs/notes/heddle".into()],
tracking: Some(GitOverlayPushTracking {
remote_name: "origin".into(),
configured_remote: Some(GitRemoteConfigured {
name: "origin".into(),
url: "https://example.com/repo.git".into(),
}),
upstream_branch: Some("main".into()),
}),
},
);
assert_eq!(outcome.output_kind, "push");
assert_eq!(outcome.transport, "git");
assert_eq!(outcome.status, "pushed");
assert!(outcome.success && outcome.pushed && outcome.changed);
assert_eq!(outcome.push_scope, Some("current_thread"));
assert_eq!(outcome.ref_scope, Some("branch_and_heddle_notes"));
assert_eq!(outcome.git_notes_ref, Some(GIT_NOTES_REF));
assert_eq!(outcome.force, Some(true));
assert_eq!(outcome.force_discard_warning, Some(FORCE_DISCARD_WARNING));
assert_eq!(outcome.tags_included, Some(false));
assert_eq!(outcome.thread.as_deref(), Some("main"));
assert_eq!(
outcome.git_upstream_configured,
Some(GitUpstreamConfigured {
branch: "main".into(),
remote: "origin".into(),
})
);
let summary = summarize_push_outcome(&outcome);
assert!(summary.contains("force-pushed"), "{summary}");
assert!(summary.contains("2 refs"), "{summary}");
}
#[test]
fn build_heddle_all_threads_push_outcome_partial_and_sorts_refs() {
let mut req = base_push_request();
req.all_threads = true;
let plan = plan_push(&req).unwrap();
let outcome = build_push_outcome(
&plan,
PushExecutionFacts::HeddleAllThreads {
pushed_threads: vec!["z".into(), "a".into()],
failed_threads: vec!["b".into()],
objects: 4,
},
);
assert_eq!(outcome.status, "partial");
assert!(!outcome.success);
assert!(!outcome.pushed);
assert_eq!(outcome.push_scope, Some("all_threads"));
assert_eq!(
outcome.refs_written.as_deref(),
Some(["a".to_string(), "z".to_string()].as_slice())
);
assert_eq!(outcome.objects, Some(4));
let summary = summarize_push_outcome(&outcome);
assert!(summary.contains("partial"), "{summary}");
}
#[test]
fn build_heddle_single_push_outcome() {
let plan = plan_push(&base_push_request()).unwrap();
let outcome = build_push_outcome(
&plan,
PushExecutionFacts::HeddleSingle {
state: Some("abc123".into()),
objects: Some(7),
},
);
assert_eq!(outcome.transport, "heddle");
assert_eq!(outcome.state.as_deref(), Some("abc123"));
assert_eq!(outcome.objects, Some(7));
assert!(outcome.refs_written.is_none());
assert!(summarize_push_outcome(&outcome).contains("abc123"));
}
#[test]
fn build_git_overlay_and_heddle_pull_outcomes() {
let plan = plan_pull(&base_pull_request()).unwrap();
let git = build_pull_outcome(
Some(&plan),
PullExecutionFacts::GitOverlay {
remote: "origin".into(),
branch: Some("main".into()),
old_git_head: Some("old".into()),
new_git_head: Some("new".into()),
old_state: Some("s0".into()),
new_state: Some("s1".into()),
changed: true,
states_created: 2,
commits_seen: 5,
materialized_checkout: true,
changed_paths: vec!["a.rs".into(), "b.rs".into()],
},
);
assert_eq!(git.status, "updated");
assert_eq!(git.transport, "git");
assert_eq!(git.changed_path_count, Some(2));
assert_eq!(git.commits_seen_scope, Some(COMMITS_SEEN_SCOPE));
assert!(git.pulled && git.changed);
assert!(summarize_pull_outcome(&git).contains("2 changed paths"));
let heddle = build_pull_outcome(
Some(&plan),
PullExecutionFacts::Heddle {
changed: false,
remote: "/tmp/src".into(),
thread: "main".into(),
state: Some("s1".into()),
objects: Some(0),
},
);
assert_eq!(heddle.status, "up_to_date");
assert!(!heddle.pulled);
assert_eq!(heddle.thread.as_deref(), Some("main"));
assert!(summarize_pull_outcome(&heddle).contains("up to date"));
}
#[test]
fn push_and_pull_status_helpers() {
assert_eq!(push_status(true), "pushed");
assert_eq!(push_status(false), "partial");
assert_eq!(pull_status(true), "updated");
assert_eq!(pull_status(false), "up_to_date");
assert_eq!(push_scope_label(true), "all_threads");
assert_eq!(push_scope_label(false), "current_thread");
assert_eq!(
git_overlay_ref_scope(true),
"all_threads_tags_and_heddle_notes"
);
assert_eq!(git_overlay_ref_scope(false), "branch_and_heddle_notes");
}
#[test]
fn push_failure_advice_kinds_map_to_recovery_kinds() {
assert_eq!(
PushFailure::Preflight(RemotePreflightBlocker::MissingRemote).advice_kind(),
remote_advice_kind::REMOTE_NOT_CONFIGURED
);
assert_eq!(
PushFailure::Preflight(RemotePreflightBlocker::TransportMismatch).advice_kind(),
remote_advice_kind::REMOTE_TRANSPORT_MISMATCH
);
assert_eq!(
PushFailure::Preflight(RemotePreflightBlocker::GitOverlayThreadMismatch {
requested: "feature".into(),
attached: Some("main".into()),
})
.advice_kind(),
remote_advice_kind::GIT_OVERLAY_THREAD_MISMATCH
);
assert_eq!(
named_thread_tip_mismatch_failure("feat", "aaa", "bbb").advice_kind(),
remote_advice_kind::NAMED_THREAD_TIP_MISMATCH
);
assert_eq!(
PushFailure::RemoteFailed {
track_name: "main".into(),
error: "boom".into(),
}
.advice_kind(),
remote_advice_kind::REMOTE_PUSH_FAILED
);
}
#[test]
fn pull_failure_advice_kinds_map_to_recovery_kinds() {
assert_eq!(
PullFailure::LocalLazyUnsupported {
source_path: "/tmp/src".into(),
}
.advice_kind(),
remote_advice_kind::LOCAL_LAZY_PULL_UNSUPPORTED
);
assert_eq!(
PullFailure::RemoteFailed {
remote_thread: "main".into(),
local_thread: None,
error: "no".into(),
}
.advice_kind(),
remote_advice_kind::REMOTE_PULL_FAILED
);
}
#[test]
fn named_thread_tip_overwrite_guard_table() {
let cases = [
(false, Some("feat"), true, true),
(true, Some("feat"), true, false),
(false, Some("feat"), false, false),
(false, None, true, false),
(false, None, false, false),
];
for (force, named, differs, refuse) in cases {
assert_eq!(
refuse_named_thread_tip_overwrite(force, named, differs),
refuse,
"force={force} named={named:?} differs={differs}"
);
}
}
#[test]
fn first_multi_thread_push_failure_picks_first() {
assert!(first_multi_thread_push_failure(&[]).is_none());
let failure = first_multi_thread_push_failure(&[
("a".into(), "e1".into()),
("b".into(), "e2".into()),
])
.unwrap();
assert_eq!(
failure,
PushFailure::RemoteFailed {
track_name: "a".into(),
error: "e1".into(),
}
);
}
#[test]
fn transport_error_message_defaults_and_trims() {
assert_eq!(transport_error_message(None), UNKNOWN_TRANSPORT_ERROR);
assert_eq!(transport_error_message(Some("")), UNKNOWN_TRANSPORT_ERROR);
assert_eq!(
transport_error_message(Some(" ")),
UNKNOWN_TRANSPORT_ERROR
);
assert_eq!(transport_error_message(Some(" boom ")), "boom");
}
#[test]
fn remote_push_and_pull_failure_from_transport_errors() {
assert_eq!(
remote_push_failure("main", None),
PushFailure::RemoteFailed {
track_name: "main".into(),
error: UNKNOWN_TRANSPORT_ERROR.into(),
}
);
assert_eq!(
remote_push_failure("feat", Some("refused")),
PushFailure::RemoteFailed {
track_name: "feat".into(),
error: "refused".into(),
}
);
assert_eq!(
remote_pull_failure("main", Some("local"), None),
PullFailure::RemoteFailed {
remote_thread: "main".into(),
local_thread: Some("local".into()),
error: UNKNOWN_TRANSPORT_ERROR.into(),
}
);
assert_eq!(
remote_pull_failure("main", None, Some("gone")),
PullFailure::RemoteFailed {
remote_thread: "main".into(),
local_thread: None,
error: "gone".into(),
}
);
}
#[test]
fn multi_thread_reported_refs_and_execution_facts() {
let failures = [("b".into(), "e".into()), ("c".into(), "e2".into())];
assert_eq!(
multi_thread_failed_names(&failures),
vec!["b".to_string(), "c".to_string()]
);
assert_eq!(
multi_thread_reported_refs(&["z".into(), "a".into()]),
vec!["a".to_string(), "z".to_string()]
);
let facts = multi_thread_push_execution_facts(vec!["z".into(), "a".into()], &failures, 3);
assert_eq!(
facts,
PushExecutionFacts::HeddleAllThreads {
pushed_threads: vec!["z".into(), "a".into()],
failed_threads: vec!["b".into(), "c".into()],
objects: 3,
}
);
let mut req = base_push_request();
req.all_threads = true;
let plan = plan_push(&req).unwrap();
let outcome = build_push_outcome(&plan, facts);
assert_eq!(
outcome.refs_written.as_deref(),
Some(["a".to_string(), "z".to_string()].as_slice())
);
assert_eq!(outcome.status, "partial");
}
#[test]
fn all_threads_mirror_coverage_note_policy() {
assert_eq!(
all_threads_mirror_coverage_note(true),
Some(ALL_THREADS_MIRROR_COVERS_NOTE)
);
assert_eq!(all_threads_mirror_coverage_note(false), None);
}
#[test]
fn hosted_push_result_parse_and_execution_facts() {
let ok = HostedPushResultFields {
success: true,
new_state: Some("s1".into()),
error: None,
};
assert_eq!(
parse_hosted_push_result("main", &ok),
HostedPushResult::Success {
state: Some("s1".into())
}
);
assert_eq!(
heddle_single_push_execution_facts_from_hosted(&ok),
PushExecutionFacts::HeddleSingle {
state: Some("s1".into()),
objects: None,
}
);
let fail = HostedPushResultFields {
success: false,
new_state: None,
error: Some(" refused ".into()),
};
assert_eq!(
parse_hosted_push_result("feat", &fail),
HostedPushResult::Failed(PushFailure::RemoteFailed {
track_name: "feat".into(),
error: "refused".into(),
})
);
let local = LocalTransferSummary {
state: Some("abc".into()),
objects: Some(3),
};
assert_eq!(
heddle_single_push_execution_facts_from_local(&local),
PushExecutionFacts::HeddleSingle {
state: Some("abc".into()),
objects: Some(3),
}
);
}
#[test]
fn hosted_pull_result_parse_and_execution_facts() {
let ok = HostedPullResultFields {
success: true,
final_state: Some("s9".into()),
error: None,
};
assert_eq!(
parse_hosted_pull_result("main", Some("local"), &ok),
HostedPullResult::Success {
final_state: Some("s9".into())
}
);
assert_eq!(
heddle_pull_execution_facts_from_hosted(true, "origin".into(), "main".into(), &ok),
PullExecutionFacts::Heddle {
changed: true,
remote: "origin".into(),
thread: "main".into(),
state: Some("s9".into()),
objects: None,
}
);
let fail = HostedPullResultFields {
success: false,
final_state: None,
error: None,
};
assert_eq!(
parse_hosted_pull_result("main", None, &fail),
HostedPullResult::Failed(PullFailure::RemoteFailed {
remote_thread: "main".into(),
local_thread: None,
error: UNKNOWN_TRANSPORT_ERROR.into(),
})
);
assert!(pull_tip_changed(Some("a"), Some("b")));
assert!(!pull_tip_changed(Some("a"), Some("a")));
assert!(!pull_tip_changed(Some("a"), None));
assert!(local_pull_changed(Some("a"), "a", 1));
assert!(!local_pull_changed(Some("a"), "a", 0));
}
#[test]
fn multi_ref_progress_constructors_and_ref_list() {
assert_eq!(
multi_ref_push_begin("file:///tmp/r"),
MultiRefPushProgress::Begin {
target: "file:///tmp/r".into(),
}
);
let local = multi_ref_thread_succeeded_local("main", Some("abc".into()), Some(2));
assert_eq!(
format_multi_ref_push_progress(&local),
"pushed abc to main (2 objects)"
);
let hosted_fields = HostedPushResultFields {
success: true,
new_state: Some("s1".into()),
error: None,
};
assert_eq!(
multi_ref_progress_from_hosted_thread("feat", &hosted_fields),
multi_ref_thread_succeeded_hosted("feat", Some("s1".into()))
);
let fail_fields = HostedPushResultFields {
success: false,
new_state: None,
error: Some("boom".into()),
};
assert_eq!(
format_multi_ref_push_progress(&multi_ref_progress_from_hosted_thread(
"x",
&fail_fields
)),
"failed to push x: boom"
);
assert_eq!(
format_ref_list(&["b".into(), "a".into()]),
"b, a".to_string()
);
assert_eq!(
format_multi_thread_refs_detail(&["z".into(), "a".into()]).as_deref(),
Some("refs: a, z")
);
assert!(format_multi_thread_refs_detail(&[]).is_none());
}
#[test]
fn working_and_mirror_text_helpers() {
assert_eq!(format_pushing_to("file:///r"), "pushing to file:///r");
assert_eq!(format_pulling_from("file:///s"), "pulling from file:///s");
assert_eq!(
format_connected_to("127.0.0.1:1"),
"connected to 127.0.0.1:1"
);
assert_eq!(format_remote_state_detail("s1"), "remote state: s1");
assert_eq!(format_mirror_success_text("origin"), "mirrored to origin");
assert!(format_mirror_failure_text("m", "e").contains("mirror push to m failed"));
}
#[test]
fn multi_ref_push_progress_formatting() {
assert_eq!(
format_multi_ref_push_progress(&MultiRefPushProgress::Begin {
target: "file:///tmp/r".into(),
}),
"pushing all threads to file:///tmp/r"
);
assert_eq!(
format_multi_ref_push_progress(&MultiRefPushProgress::ThreadSucceeded {
thread: "main".into(),
state_short: Some("abc".into()),
objects: Some(1),
remote_state: None,
}),
"pushed abc to main (1 object)"
);
assert_eq!(
format_multi_ref_push_progress(&MultiRefPushProgress::ThreadSucceeded {
thread: "main".into(),
state_short: Some("abc".into()),
objects: Some(2),
remote_state: None,
}),
"pushed abc to main (2 objects)"
);
assert_eq!(
format_multi_ref_push_progress(&MultiRefPushProgress::ThreadSucceeded {
thread: "feat".into(),
state_short: None,
objects: None,
remote_state: Some("s1".into()),
}),
"pushed to feat (remote state s1)"
);
assert_eq!(
format_multi_ref_push_progress(&MultiRefPushProgress::ThreadFailed {
thread: "x".into(),
error: "nope".into(),
}),
"failed to push x: nope"
);
}
#[test]
fn format_push_outcome_text_git_overlay_details() {
let mut req = base_push_request();
req.capability = RepositoryCapability::GitOverlay;
req.force = true;
let plan = plan_push(&req).unwrap();
let outcome = build_push_outcome(
&plan,
PushExecutionFacts::GitOverlayRefs {
remote_name: "origin".into(),
current_thread: Some("main".into()),
refs_written: vec!["refs/heads/main".into()],
tracking: Some(GitOverlayPushTracking {
remote_name: "origin".into(),
configured_remote: Some(GitRemoteConfigured {
name: "origin".into(),
url: "https://example.com/r.git".into(),
}),
upstream_branch: Some("main".into()),
}),
},
);
let text = format_push_outcome_text(&outcome, None);
assert!(
text.headline.contains("pushed thread main to origin"),
"{}",
text.headline
);
assert!(
text.detail_lines.iter().any(|l| l.starts_with("Force:")),
"{:?}",
text.detail_lines
);
assert!(
text.detail_lines
.iter()
.any(|l| l.contains("refs/notes/heddle")),
"{:?}",
text.detail_lines
);
assert!(
text.detail_lines
.iter()
.any(|l| l.contains("tracks origin/main")),
"{:?}",
text.detail_lines
);
}
#[test]
fn format_pull_outcome_text_up_to_date_and_paths() {
let plan = plan_pull(&base_pull_request()).unwrap();
let up = build_pull_outcome(
Some(&plan),
PullExecutionFacts::Heddle {
changed: false,
remote: "origin".into(),
thread: "main".into(),
state: None,
objects: None,
},
);
let text = format_pull_outcome_text(&up, 8);
assert!(text.headline.contains("already up to date with origin"));
let git = build_pull_outcome(
Some(&plan),
PullExecutionFacts::GitOverlay {
remote: "origin".into(),
branch: Some("main".into()),
old_git_head: None,
new_git_head: None,
old_state: None,
new_state: None,
changed: true,
states_created: 1,
commits_seen: 3,
materialized_checkout: false,
changed_paths: vec!["a".into(), "b".into(), "c".into()],
},
);
let text = format_pull_outcome_text(&git, 2);
assert_eq!(text.headline, "pulled from origin");
assert!(text.detail_lines.iter().any(|l| l == "Changed paths: 3"));
assert!(text.detail_lines.iter().any(|l| l == " - ... 1 more"));
}
#[test]
fn pull_should_materialize_respects_lazy() {
assert!(pull_should_materialize(true, false));
assert!(!pull_should_materialize(true, true));
assert!(!pull_should_materialize(false, false));
assert!(!pull_should_materialize(false, true));
}
#[test]
fn pure_remote_url_and_hosted_path_helpers() {
assert!(looks_like_git_remote_url("https://example.com/r.git"));
assert!(looks_like_git_remote_url("git@github.com:org/r.git"));
assert!(!looks_like_git_remote_url("origin"));
assert!(looks_like_remote_location("/tmp/repo"));
assert!(looks_like_remote_location("~/src/repo"));
assert!(looks_like_remote_location("ssh://host/path"));
assert!(!looks_like_remote_location("origin"));
assert!(remote_urls_match("same", "same"));
assert!(message_indicates_already_exists("Spool already exists"));
assert!(!message_indicates_already_exists("not found"));
assert!(hosted_path_contains_internal_user_namespace(
"__users/abc/spool"
));
assert_eq!(
redact_internal_hosted_paths("fail __users/u1/x more"),
"fail [user namespace] more"
);
assert_eq!(
hosted_spool_display_path("ns", "slug", "__users/u/ns/slug"),
"ns/slug"
);
assert_eq!(
hosted_spool_display_path("ns", "slug", "ns/slug"),
"ns/slug"
);
assert!(!is_native_transport_mismatch(
RepositoryCapability::GitOverlay,
true
));
assert!(is_native_transport_mismatch(
RepositoryCapability::NativeHeddle,
true
));
assert!(!is_native_transport_mismatch(
RepositoryCapability::NativeHeddle,
false
));
}
}