mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
Merge pull request #826 from fabro-sh/codex/run-intent-producer-support
Add local RunIntent producer support
This commit is contained in:
commit
16c4d671be
12 changed files with 1483 additions and 88 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2826,6 +2826,7 @@ dependencies = [
|
|||
"fabro-template",
|
||||
"fabro-test",
|
||||
"fabro-types",
|
||||
"fabro-util",
|
||||
"fabro-workflow",
|
||||
"fabro-workflow-version",
|
||||
"git2",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Result, anyhow};
|
||||
use fabro_api::types;
|
||||
use fabro_config::RunLayer;
|
||||
use fabro_config::{RunLayer, WorkflowSettingsBuilder};
|
||||
use fabro_manifest::CollectedWorkflowClosure;
|
||||
use fabro_workflow::operations::{ValidateInput, WorkflowInput, validate};
|
||||
use fabro_workflow::pipeline::TEMPLATE_UNDEFINED_VARIABLE_RULE;
|
||||
|
||||
use crate::run_manifest;
|
||||
use crate::{run_intent, run_manifest};
|
||||
|
||||
/// Validate a manifest without a model catalog.
|
||||
///
|
||||
|
|
@ -28,6 +31,54 @@ pub fn validate_manifest(
|
|||
Ok(run_manifest::validate_response(&prepared, &validated))
|
||||
}
|
||||
|
||||
/// Validate an already collected local workflow before any version upload.
|
||||
///
|
||||
/// The supplied run layer is complete, including any already resolved inline
|
||||
/// goal. Validation uses only seeded environment defaults, immutable workflow
|
||||
/// settings, and explicit inputs; it performs no store, HTTP, user-settings,
|
||||
/// project-settings, or model-catalog operation. Undefined template variables
|
||||
/// are promoted to errors before the response is returned.
|
||||
pub fn validate_collected_workflow(
|
||||
closure: &CollectedWorkflowClosure,
|
||||
run_overrides: Option<&RunLayer>,
|
||||
input_overrides: &HashMap<String, toml::Value>,
|
||||
) -> Result<types::ValidateResponse> {
|
||||
let lowered = run_intent::lower_collected_workflow_closure(closure)?;
|
||||
let workflow = lowered
|
||||
.workflow_bundle
|
||||
.into_workflows()
|
||||
.remove(&lowered.entrypoint)
|
||||
.ok_or_else(|| anyhow!("lowered root workflow is missing from its bundle"))?;
|
||||
let mut builder = WorkflowSettingsBuilder::new()
|
||||
.server_manifest_defaults(
|
||||
RunLayer::default(),
|
||||
fabro_environment::seeded_catalog_layer(),
|
||||
)
|
||||
.server_mcp_catalog(HashMap::new());
|
||||
if let Some(run) = run_overrides {
|
||||
builder = builder.run_overrides(run.clone());
|
||||
}
|
||||
if let Some(layer) = lowered.workflow_layer {
|
||||
builder = builder.workflow_layer(layer);
|
||||
}
|
||||
let mut settings = builder.build().map_err(anyhow::Error::new)?;
|
||||
settings.run.inputs.extend(input_overrides.clone());
|
||||
let validated = validate(ValidateInput {
|
||||
workflow: WorkflowInput::Bundled(workflow),
|
||||
settings,
|
||||
vars: HashMap::new(),
|
||||
cwd: PathBuf::from("/workspace"),
|
||||
custom_transforms: Vec::new(),
|
||||
})
|
||||
.map_err(anyhow::Error::new)?;
|
||||
let mut response = types::ValidateResponse {
|
||||
ok: !validated.has_errors(),
|
||||
workflow: run_manifest::workflow_summary(&validated, lowered.entrypoint.as_path()),
|
||||
};
|
||||
promote_template_undefined_variables_to_errors(&mut response);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub fn promote_template_undefined_variables_to_errors(response: &mut types::ValidateResponse) {
|
||||
let mut promoted = false;
|
||||
for diagnostic in &mut response.workflow.diagnostics {
|
||||
|
|
@ -40,3 +91,160 @@ pub fn promote_template_undefined_variables_to_errors(response: &mut types::Vali
|
|||
response.ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "collected-validation tests write isolated workflow fixtures synchronously"
|
||||
)]
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use fabro_config::RunGoalLayer;
|
||||
use fabro_types::settings::InterpString;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn write(root: &Path, path: &str, content: &str) {
|
||||
let path = root.join(path);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, content).unwrap();
|
||||
}
|
||||
|
||||
fn write_complete_fixture(root: &Path) -> PathBuf {
|
||||
write(
|
||||
root,
|
||||
"root/workflow.toml",
|
||||
r#"_version = 1
|
||||
[workflow]
|
||||
graph = "workflow.fabro"
|
||||
[run.goal]
|
||||
file = "goal.md"
|
||||
[run.environment.image]
|
||||
dockerfile = { path = "Dockerfile" }
|
||||
"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"root/workflow.fabro",
|
||||
r#"digraph Root {
|
||||
start [shape=Mdiamond]
|
||||
imported [import="imports/shared.fabro"]
|
||||
task [prompt="@prompts/task.md", model="future-provider/future-model"]
|
||||
child [stack.child_workflow="../child/workflow.fabro"]
|
||||
exit [shape=Msquare]
|
||||
start -> imported -> task -> child -> exit
|
||||
}"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"root/imports/shared.fabro",
|
||||
"digraph Shared { start [shape=Mdiamond] shared [prompt=\"shared\"] exit \
|
||||
[shape=Msquare] start -> shared -> exit }",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"root/prompts/task.md",
|
||||
"Hello {{ inputs.owner }}. {% include \"detail.md\" %}",
|
||||
);
|
||||
write(root, "root/prompts/detail.md", "detail");
|
||||
write(root, "root/goal.md", "workflow goal");
|
||||
write(root, "root/Dockerfile", "FROM alpine\n");
|
||||
write(
|
||||
root,
|
||||
"child/workflow.fabro",
|
||||
"digraph Child { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }",
|
||||
);
|
||||
root.join("root/workflow.toml")
|
||||
}
|
||||
|
||||
fn owner_input() -> HashMap<String, toml::Value> {
|
||||
HashMap::from([("owner".to_string(), toml::Value::String("Ada".to_string()))])
|
||||
}
|
||||
|
||||
fn run_overrides(goal: &str) -> RunLayer {
|
||||
RunLayer {
|
||||
goal: Some(RunGoalLayer::Inline(InterpString::parse(goal))),
|
||||
..RunLayer::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collected_validation_matches_legacy_response_for_equivalent_inputs() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let workflow = write_complete_fixture(temp.path());
|
||||
let run = run_overrides("inline goal");
|
||||
let inputs = owner_input();
|
||||
let package = fabro_manifest::resolve_local_workflow_package(
|
||||
&workflow,
|
||||
temp.path(),
|
||||
Some(temp.path()),
|
||||
)
|
||||
.unwrap();
|
||||
let manifest = fabro_manifest::build_run_manifest(fabro_manifest::ManifestBuildInput {
|
||||
workflow,
|
||||
cwd: temp.path().to_path_buf(),
|
||||
run_overrides: Some(run.clone()),
|
||||
input_overrides: inputs.clone(),
|
||||
args: Some(types::ManifestArgs {
|
||||
input: vec!["owner=Ada".to_string()],
|
||||
..types::ManifestArgs::default()
|
||||
}),
|
||||
environment_defaults: fabro_environment::seeded_catalog_layer(),
|
||||
..fabro_manifest::ManifestBuildInput::default()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut legacy = validate_manifest(&RunLayer::default(), &manifest.manifest).unwrap();
|
||||
promote_template_undefined_variables_to_errors(&mut legacy);
|
||||
let collected =
|
||||
validate_collected_workflow(package.closure(), Some(&run), &inputs).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(collected).unwrap(),
|
||||
serde_json::to_value(legacy).unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collected_validation_promotes_undefined_inputs_and_accepts_explicit_values() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let workflow = write_complete_fixture(temp.path());
|
||||
let package = fabro_manifest::resolve_local_workflow_package(
|
||||
&workflow,
|
||||
temp.path(),
|
||||
Some(temp.path()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let missing =
|
||||
validate_collected_workflow(package.closure(), None, &HashMap::new()).unwrap();
|
||||
assert!(!missing.ok);
|
||||
assert!(missing.workflow.diagnostics.iter().any(|diagnostic| {
|
||||
diagnostic.rule == TEMPLATE_UNDEFINED_VARIABLE_RULE
|
||||
&& diagnostic.severity == types::WorkflowDiagnosticSeverity::Error
|
||||
}));
|
||||
|
||||
let present = validate_collected_workflow(
|
||||
package.closure(),
|
||||
Some(&run_overrides("resolved inline goal")),
|
||||
&owner_input(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
present
|
||||
.workflow
|
||||
.diagnostics
|
||||
.iter()
|
||||
.all(|diagnostic| { diagnostic.rule != TEMPLATE_UNDEFINED_VARIABLE_RULE })
|
||||
);
|
||||
assert!(present.ok);
|
||||
assert_eq!(present.workflow.goal, "resolved inline goal");
|
||||
assert!(present.workflow.diagnostics.iter().all(|diagnostic| {
|
||||
!diagnostic.message.contains("future-provider")
|
||||
&& !diagnostic.message.contains("future-model")
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ use std::path::{Component, Path, PathBuf};
|
|||
use fabro_config::parse::SettingsSource;
|
||||
use fabro_config::{EnvironmentLayer, RunEnvironmentLayer, RunGoalLayer, SettingsLayer};
|
||||
use fabro_environment::{EnvironmentId, EnvironmentValidationError};
|
||||
use fabro_manifest::CollectedWorkflowClosure;
|
||||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::{
|
||||
GitContext, ManifestPath, RunTarget, SandboxProviderKind, TargetValidationError, WorkflowPath,
|
||||
WorkflowVersionId,
|
||||
WorkflowVersion, WorkflowVersionId,
|
||||
};
|
||||
use fabro_workflow::git;
|
||||
use fabro_workflow::workflow_bundle::{BundledWorkflow, ParsedWorkflowConfig, WorkflowBundle};
|
||||
|
|
@ -183,8 +184,69 @@ pub(crate) enum WorkflowClosureLoweringError {
|
|||
},
|
||||
}
|
||||
|
||||
pub(crate) fn lower_workflow_closure(
|
||||
closure: &LoadedWorkflowVersionClosure,
|
||||
/// Read access to a workflow-version closure, whether loaded from the store
|
||||
/// or collected from a local checkout, so both lower through one path.
|
||||
trait WorkflowClosureView {
|
||||
fn root_id(&self) -> WorkflowVersionId;
|
||||
fn validated_root(&self) -> &ValidatedWorkflowVersion;
|
||||
fn get(&self, id: &WorkflowVersionId) -> Option<&WorkflowVersion>;
|
||||
|
||||
fn root(&self) -> &WorkflowVersion {
|
||||
self.validated_root().version()
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkflowClosureView for LoadedWorkflowVersionClosure {
|
||||
fn root_id(&self) -> WorkflowVersionId {
|
||||
self.root_id()
|
||||
}
|
||||
|
||||
fn validated_root(&self) -> &ValidatedWorkflowVersion {
|
||||
self.validated_root()
|
||||
}
|
||||
|
||||
fn get(&self, id: &WorkflowVersionId) -> Option<&WorkflowVersion> {
|
||||
self.get(id)
|
||||
}
|
||||
}
|
||||
|
||||
struct CollectedWorkflowClosureView<'a> {
|
||||
root_id: WorkflowVersionId,
|
||||
root: &'a ValidatedWorkflowVersion,
|
||||
versions: HashMap<WorkflowVersionId, &'a ValidatedWorkflowVersion>,
|
||||
}
|
||||
|
||||
impl<'a> CollectedWorkflowClosureView<'a> {
|
||||
fn new(closure: &'a CollectedWorkflowClosure) -> Result<Self, WorkflowClosureLoweringError> {
|
||||
let root_id = closure.root_id();
|
||||
let versions = closure.versions().collect::<HashMap<_, _>>();
|
||||
let root = *versions
|
||||
.get(&root_id)
|
||||
.ok_or(WorkflowClosureLoweringError::MissingVersion { id: root_id })?;
|
||||
Ok(Self {
|
||||
root_id,
|
||||
root,
|
||||
versions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkflowClosureView for CollectedWorkflowClosureView<'_> {
|
||||
fn root_id(&self) -> WorkflowVersionId {
|
||||
self.root_id
|
||||
}
|
||||
|
||||
fn validated_root(&self) -> &ValidatedWorkflowVersion {
|
||||
self.root
|
||||
}
|
||||
|
||||
fn get(&self, id: &WorkflowVersionId) -> Option<&WorkflowVersion> {
|
||||
self.versions.get(id).map(|version| version.version())
|
||||
}
|
||||
}
|
||||
|
||||
fn lower_workflow_closure_view(
|
||||
closure: &impl WorkflowClosureView,
|
||||
) -> Result<LoweredWorkflowClosure, WorkflowClosureLoweringError> {
|
||||
let entrypoint = manifest_path(closure.root().entrypoint(), closure.root().entrypoint())?;
|
||||
let mut mounts = HashMap::new();
|
||||
|
|
@ -227,6 +289,19 @@ pub(crate) fn lower_workflow_closure(
|
|||
})
|
||||
}
|
||||
|
||||
pub(crate) fn lower_workflow_closure(
|
||||
closure: &LoadedWorkflowVersionClosure,
|
||||
) -> Result<LoweredWorkflowClosure, WorkflowClosureLoweringError> {
|
||||
lower_workflow_closure_view(closure)
|
||||
}
|
||||
|
||||
pub(crate) fn lower_collected_workflow_closure(
|
||||
closure: &CollectedWorkflowClosure,
|
||||
) -> Result<LoweredWorkflowClosure, WorkflowClosureLoweringError> {
|
||||
let view = CollectedWorkflowClosureView::new(closure)?;
|
||||
lower_workflow_closure_view(&view)
|
||||
}
|
||||
|
||||
pub(crate) fn pin_workflow_environment_authority(layer: &mut SettingsLayer, environment_id: &str) {
|
||||
// Both blocks destructure without `..` so adding a field to either layer
|
||||
// type forces a compile-time decision here: server-owned facts are
|
||||
|
|
@ -262,7 +337,7 @@ pub(crate) fn pin_workflow_environment_authority(layer: &mut SettingsLayer, envi
|
|||
}
|
||||
|
||||
fn mount_version(
|
||||
closure: &LoadedWorkflowVersionClosure,
|
||||
closure: &impl WorkflowClosureView,
|
||||
id: WorkflowVersionId,
|
||||
mounted_entrypoint: ManifestPath,
|
||||
mounts: &mut HashMap<ManifestPath, WorkflowVersionId>,
|
||||
|
|
@ -460,6 +535,58 @@ mod tests {
|
|||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collected_and_stored_closures_lower_through_the_same_path() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("root");
|
||||
let child = temp.path().join("child");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::create_dir_all(&child).unwrap();
|
||||
fs::write(
|
||||
root.join("workflow.toml"),
|
||||
"_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n[run.goal]\nfile = \"goal.md\"\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("workflow.fabro"),
|
||||
"digraph Root { child [stack.child_workflow=\"../child/workflow.fabro\"] }",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
fs::write(root.join("goal.md"), "Ship it").await.unwrap();
|
||||
fs::write(child.join("workflow.fabro"), "digraph Child {}")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let collected =
|
||||
fabro_manifest::collect_workflow_versions(&root.join("workflow.toml"), temp.path())
|
||||
.unwrap();
|
||||
let (database, _) = crate::test_support::test_store_bundle();
|
||||
let store = WorkflowVersionStore::new(database.blobs());
|
||||
for (_, version) in collected.versions() {
|
||||
store.put(version).await.unwrap();
|
||||
}
|
||||
let stored = store
|
||||
.get_closure(&collected.root_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let from_collected = lower_collected_workflow_closure(&collected).unwrap();
|
||||
let from_stored = lower_workflow_closure(&stored).unwrap();
|
||||
|
||||
assert_eq!(from_collected.entrypoint, from_stored.entrypoint);
|
||||
assert_eq!(
|
||||
serde_json::to_value(from_collected.workflow_bundle.workflows()).unwrap(),
|
||||
serde_json::to_value(from_stored.workflow_bundle.workflows()).unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
format!("{:?}", from_collected.workflow_layer),
|
||||
format!("{:?}", from_stored.workflow_layer),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepares_a_canonical_folder_target_without_git_projection() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -1563,7 +1563,10 @@ fn preflight_response(
|
|||
}
|
||||
}
|
||||
|
||||
fn workflow_summary(validated: &Validated, target_path: &Path) -> types::PreflightWorkflowSummary {
|
||||
pub(crate) fn workflow_summary(
|
||||
validated: &Validated,
|
||||
target_path: &Path,
|
||||
) -> types::PreflightWorkflowSummary {
|
||||
types::PreflightWorkflowSummary {
|
||||
diagnostics: diagnostics_to_api(validated.diagnostics()),
|
||||
edges: i64::try_from(validated.graph().edges.len())
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ toml.workspace = true
|
|||
|
||||
[dev-dependencies]
|
||||
fabro-test.workspace = true
|
||||
fabro-util = { path = "../../foundation/fabro-util" }
|
||||
insta.workspace = true
|
||||
serde_json.workspace = true
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
reason = "CLI manifest builder: sync file I/O building install manifests"
|
||||
)]
|
||||
|
||||
mod local_workflow_package;
|
||||
mod workflow_bundler;
|
||||
mod workflow_version_collector;
|
||||
|
||||
|
|
@ -24,11 +25,15 @@ use fabro_template::validate_static_reference;
|
|||
use fabro_types::graph::ReferenceKind;
|
||||
use fabro_types::settings::interp::InterpString;
|
||||
use fabro_types::settings::run::{ApprovalMode, ResolvedGoalSource, ResolvedRunGoal, RunMode};
|
||||
use fabro_types::{DirtyStatus, GitContext, ManifestPath, WorkflowSettings};
|
||||
use fabro_workflow::git::{
|
||||
GitSyncStatus, branch_needs_push, head_sha, push_branch_noninteractive, sync_status,
|
||||
use fabro_types::{
|
||||
DirtyStatus, GitContext, GitHubRepositorySlug, GitRunTarget, ManifestPath, RunTarget,
|
||||
WorkflowSettings,
|
||||
};
|
||||
use fabro_workflow::git::{self, GitSyncStatus};
|
||||
|
||||
pub use crate::local_workflow_package::{
|
||||
LocalWorkflowPackageError, ResolvedLocalWorkflowPackage, resolve_local_workflow_package,
|
||||
};
|
||||
use crate::workflow_bundler::WorkflowBundler;
|
||||
pub use crate::workflow_version_collector::{
|
||||
CollectedWorkflowClosure, WorkflowVersionCollectError, collect_workflow_versions,
|
||||
|
|
@ -210,7 +215,7 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
|
|||
)?;
|
||||
|
||||
let configured_repo_origin_url = configured_repo_origin_url(&workflow_settings);
|
||||
let git = build_git_context(&working_directory, configured_repo_origin_url.as_deref());
|
||||
let git = build_legacy_git_context(&working_directory, configured_repo_origin_url.as_deref());
|
||||
let args = input.args.filter(|args| !manifest_args_is_empty(args));
|
||||
|
||||
Ok(BuiltManifest {
|
||||
|
|
@ -300,13 +305,76 @@ fn resolved_goal_to_manifest(resolved: ResolvedRunGoal) -> types::ManifestGoal {
|
|||
}
|
||||
}
|
||||
|
||||
fn build_git_context(
|
||||
/// Facts observed from one usable attached local Git checkout.
|
||||
///
|
||||
/// The optional target is absent when the checkout cannot be represented as a
|
||||
/// valid GitHub run target. Its SHA is present only when a successful push or
|
||||
/// a direct query of the remote proves that exact commit is available. The
|
||||
/// legacy projection retains the historical local SHA and normalized-origin
|
||||
/// behavior independently.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GitRunTargetObservation {
|
||||
pub run_target: Option<GitRunTarget>,
|
||||
pub legacy_git_context: GitContext,
|
||||
}
|
||||
|
||||
/// Observe Git facts without choosing an environment or a non-Git target.
|
||||
///
|
||||
/// Outer `None` means `repo_path` is not a usable attached checkout. A
|
||||
/// returned observation with no target means Git facts were available but the
|
||||
/// effective origin or attached branch cannot be represented by a valid
|
||||
/// GitHub run target. For a valid target, this operation may contact the
|
||||
/// remote and may make one noninteractive best-effort push of the attached
|
||||
/// branch so clone-based execution can resolve the observed commit.
|
||||
#[must_use]
|
||||
pub fn observe_git_run_target(
|
||||
repo_path: &Path,
|
||||
configured_repo_origin_url: Option<&str>,
|
||||
) -> Option<GitContext> {
|
||||
let (origin_url, branch) = detect_manifest_repo_info(repo_path)?;
|
||||
let sha = head_sha(repo_path).ok();
|
||||
let dirty = match sync_status(repo_path, "origin", Some(&branch)) {
|
||||
) -> Option<GitRunTargetObservation> {
|
||||
let local = inspect_local_git(repo_path, configured_repo_origin_url)?;
|
||||
let legacy_git_context = local.legacy_git_context;
|
||||
let mut run_target = github_run_target(
|
||||
&legacy_git_context.origin_url,
|
||||
&legacy_git_context.branch,
|
||||
None,
|
||||
);
|
||||
if let Some(target) = run_target.as_mut() {
|
||||
let publish_status = publish_manifest_branch_best_effort(
|
||||
repo_path,
|
||||
&legacy_git_context.branch,
|
||||
local.push_origin_url.as_deref(),
|
||||
configured_repo_origin_url,
|
||||
);
|
||||
target.sha = remotely_available_sha(
|
||||
repo_path,
|
||||
&legacy_git_context.branch,
|
||||
legacy_git_context.sha.as_deref(),
|
||||
publish_status,
|
||||
);
|
||||
}
|
||||
|
||||
Some(GitRunTargetObservation {
|
||||
run_target,
|
||||
legacy_git_context,
|
||||
})
|
||||
}
|
||||
|
||||
struct LocalGitObservation {
|
||||
push_origin_url: Option<String>,
|
||||
legacy_git_context: GitContext,
|
||||
}
|
||||
|
||||
fn inspect_local_git(
|
||||
repo_path: &Path,
|
||||
configured_repo_origin_url: Option<&str>,
|
||||
) -> Option<LocalGitObservation> {
|
||||
let ManifestRepoInfo {
|
||||
origin_url,
|
||||
push_origin_url,
|
||||
branch,
|
||||
sha,
|
||||
} = detect_manifest_repo_info(repo_path)?;
|
||||
let dirty = match git::sync_status(repo_path, "origin", Some(&branch)) {
|
||||
GitSyncStatus::Dirty => DirtyStatus::Dirty,
|
||||
GitSyncStatus::Synced | GitSyncStatus::Unsynced => DirtyStatus::Clean,
|
||||
};
|
||||
|
|
@ -320,18 +388,47 @@ fn build_git_context(
|
|||
.filter(|url| !url.is_empty())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
push_manifest_branch_best_effort(
|
||||
|
||||
Some(LocalGitObservation {
|
||||
push_origin_url,
|
||||
legacy_git_context: GitContext {
|
||||
origin_url: repo_origin_url,
|
||||
branch,
|
||||
sha,
|
||||
dirty,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn build_legacy_git_context(
|
||||
repo_path: &Path,
|
||||
configured_repo_origin_url: Option<&str>,
|
||||
) -> Option<GitContext> {
|
||||
let local = inspect_local_git(repo_path, configured_repo_origin_url)?;
|
||||
publish_manifest_branch_best_effort(
|
||||
repo_path,
|
||||
&branch,
|
||||
origin_url.as_deref(),
|
||||
&local.legacy_git_context.branch,
|
||||
local.push_origin_url.as_deref(),
|
||||
configured_repo_origin_url,
|
||||
);
|
||||
Some(GitContext {
|
||||
origin_url: repo_origin_url,
|
||||
branch,
|
||||
Some(local.legacy_git_context)
|
||||
}
|
||||
|
||||
fn github_run_target(origin_url: &str, branch: &str, sha: Option<String>) -> Option<GitRunTarget> {
|
||||
let (owner, repository) = fabro_github::parse_github_owner_repo(origin_url).ok()?;
|
||||
let slug = GitHubRepositorySlug::try_new(&format!("{owner}/{repository}"))?;
|
||||
let validated = RunTarget::Git(GitRunTarget {
|
||||
repo: slug.to_string(),
|
||||
branch: branch.to_owned(),
|
||||
tag: None,
|
||||
sha,
|
||||
dirty,
|
||||
})
|
||||
.validate()
|
||||
.ok()?;
|
||||
let RunTarget::Git(target) = validated.target else {
|
||||
unreachable!("a validated Git target must remain a Git target")
|
||||
};
|
||||
Some(target)
|
||||
}
|
||||
|
||||
fn configured_repo_origin_url(settings: &WorkflowSettings) -> Option<String> {
|
||||
|
|
@ -353,28 +450,67 @@ fn configured_repo_origin_url(settings: &WorkflowSettings) -> Option<String> {
|
|||
(!normalized.is_empty()).then_some(normalized)
|
||||
}
|
||||
|
||||
fn detect_manifest_repo_info(repo_path: &Path) -> Option<(Option<String>, String)> {
|
||||
struct ManifestRepoInfo {
|
||||
/// The `origin` URL as libgit2 reports it (after `insteadOf` rewrites).
|
||||
origin_url: Option<String>,
|
||||
/// The raw configured `remote.origin.url`, used to compare repository
|
||||
/// identity before pushing.
|
||||
push_origin_url: Option<String>,
|
||||
branch: String,
|
||||
sha: Option<String>,
|
||||
}
|
||||
|
||||
fn detect_manifest_repo_info(repo_path: &Path) -> Option<ManifestRepoInfo> {
|
||||
let repo = git2::Repository::discover(repo_path).ok()?;
|
||||
let branch = repo.head().ok()?.shorthand().map(ToOwned::to_owned)?;
|
||||
if repo.is_bare() {
|
||||
return None;
|
||||
}
|
||||
let head = repo.head().ok()?;
|
||||
if !head.is_branch() {
|
||||
return None;
|
||||
}
|
||||
let branch = head.shorthand().map(ToOwned::to_owned)?;
|
||||
let sha = head.target().map(|oid| oid.to_string());
|
||||
let origin_url = repo
|
||||
.find_remote("origin")
|
||||
.ok()
|
||||
.and_then(|remote| remote.url().map(ToOwned::to_owned));
|
||||
Some((origin_url, branch))
|
||||
// Keep the legacy observed URL above, but compare configured repository
|
||||
// identity against the remote's raw config bytes. This lets a repository-
|
||||
// local `url.*.insteadOf` redirect the existing push safely without making
|
||||
// the rewrite target look like a different repository.
|
||||
let push_origin_url = repo
|
||||
.config()
|
||||
.ok()
|
||||
.and_then(|config| config.get_string("remote.origin.url").ok())
|
||||
.or_else(|| origin_url.clone());
|
||||
Some(ManifestRepoInfo {
|
||||
origin_url,
|
||||
push_origin_url,
|
||||
branch,
|
||||
sha,
|
||||
})
|
||||
}
|
||||
|
||||
/// Best-effort push of the local branch so clone-based execution can see
|
||||
/// local commits. A failed push must not fail manifest creation, and the
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum BranchPublishStatus {
|
||||
TrackingRefMatches,
|
||||
Pushed,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Best-effort publication of the local branch so clone-based execution can
|
||||
/// see local commits. A failed push must not fail manifest creation, and the
|
||||
/// discarded push error may contain raw Git stderr, so it is deliberately
|
||||
/// neither returned nor logged here.
|
||||
fn push_manifest_branch_best_effort(
|
||||
fn publish_manifest_branch_best_effort(
|
||||
repo_path: &Path,
|
||||
branch: &str,
|
||||
origin_url: Option<&str>,
|
||||
configured_repo_origin_url: Option<&str>,
|
||||
) {
|
||||
) -> BranchPublishStatus {
|
||||
let Some(origin_url) = origin_url else {
|
||||
return;
|
||||
return BranchPublishStatus::Unavailable;
|
||||
};
|
||||
|
||||
if let Some(repo_origin_url) = configured_repo_origin_url
|
||||
|
|
@ -383,26 +519,57 @@ fn push_manifest_branch_best_effort(
|
|||
{
|
||||
let remote = fabro_github::normalize_repo_origin_url(origin_url);
|
||||
if remote != repo_origin_url {
|
||||
return;
|
||||
return BranchPublishStatus::Unavailable;
|
||||
}
|
||||
}
|
||||
|
||||
if !branch_needs_push(repo_path, "origin", branch) {
|
||||
return;
|
||||
if !git::branch_needs_push(repo_path, "origin", branch) {
|
||||
return BranchPublishStatus::TrackingRefMatches;
|
||||
}
|
||||
|
||||
let _ = push_branch_noninteractive(repo_path, "origin", branch);
|
||||
if git::push_branch_noninteractive(repo_path, "origin", branch).is_ok() {
|
||||
BranchPublishStatus::Pushed
|
||||
} else {
|
||||
BranchPublishStatus::Unavailable
|
||||
}
|
||||
}
|
||||
|
||||
fn remotely_available_sha(
|
||||
repo_path: &Path,
|
||||
branch: &str,
|
||||
local_sha: Option<&str>,
|
||||
publish_status: BranchPublishStatus,
|
||||
) -> Option<String> {
|
||||
let local_sha = local_sha?;
|
||||
match publish_status {
|
||||
BranchPublishStatus::Pushed => Some(local_sha.to_owned()),
|
||||
BranchPublishStatus::TrackingRefMatches => {
|
||||
git::remote_branch_sha_noninteractive(repo_path, "origin", branch)
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|remote_sha| remote_sha == local_sha)
|
||||
.map(|_| local_sha.to_owned())
|
||||
}
|
||||
BranchPublishStatus::Unavailable => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a workflow reference and reject it when neither its config nor
|
||||
/// its graph exists on disk.
|
||||
/// A missing workflow surfaces as `fabro_config::Error::WorkflowNotFound`.
|
||||
fn resolve_existing_workflow_location(workflow: &Path, cwd: &Path) -> Result<WorkflowLocation> {
|
||||
#[expect(
|
||||
clippy::result_large_err,
|
||||
reason = "callers match on the concrete config error to classify missing workflows"
|
||||
)]
|
||||
fn resolve_existing_workflow_location(
|
||||
workflow: &Path,
|
||||
cwd: &Path,
|
||||
) -> Result<WorkflowLocation, fabro_config::Error> {
|
||||
let location = WorkflowLocation::resolve(workflow, cwd)?;
|
||||
if location.toml.is_none() && !location.graph.is_file() {
|
||||
return Err(
|
||||
fabro_config::Error::WorkflowNotFound(location.graph.display().to_string()).into(),
|
||||
);
|
||||
return Err(fabro_config::Error::WorkflowNotFound(
|
||||
location.graph.display().to_string(),
|
||||
));
|
||||
}
|
||||
Ok(location)
|
||||
}
|
||||
|
|
@ -459,6 +626,8 @@ pub(crate) mod test_fixtures {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_workflow::git::head_sha;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn test_environment_defaults() -> MergeMap<EnvironmentLayer> {
|
||||
|
|
@ -1627,6 +1796,190 @@ exit 1
|
|||
assert_eq!(std::fs::read_to_string(helper_log).unwrap(), "0\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observes_synced_github_branch_as_an_exact_target() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let workspace = temp.path().join("workspace");
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
let bare_origin = init_bare_origin(temp.path());
|
||||
init_git_repo(&workspace, "feature", "https://github.com/acme/widgets.git");
|
||||
let local_url = format!("file://{}", bare_origin.display());
|
||||
run_git(&workspace, &[
|
||||
"config",
|
||||
&format!("url.{local_url}.insteadOf"),
|
||||
"https://github.com/acme/widgets.git",
|
||||
]);
|
||||
run_git(&workspace, &["push", "origin", "feature"]);
|
||||
|
||||
let observation =
|
||||
observe_git_run_target(&workspace, Some("https://github.com/acme/widgets")).unwrap();
|
||||
let target = observation.run_target.as_ref().unwrap();
|
||||
let legacy = &observation.legacy_git_context;
|
||||
|
||||
assert_eq!(target.repo, "acme/widgets");
|
||||
assert_eq!(target.branch, "feature");
|
||||
assert_eq!(target.tag, None);
|
||||
assert_eq!(target.sha, legacy.sha);
|
||||
assert_eq!(legacy.dirty, DirtyStatus::Clean);
|
||||
assert_eq!(legacy.origin_url, "https://github.com/acme/widgets");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observes_exact_sha_only_after_a_successful_noninteractive_push() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let workspace = temp.path().join("workspace");
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
let bare_origin = init_bare_origin(temp.path());
|
||||
init_git_repo(&workspace, "feature", "https://github.com/acme/widgets");
|
||||
let local_url = format!("file://{}", bare_origin.display());
|
||||
run_git(&workspace, &[
|
||||
"config",
|
||||
&format!("url.{local_url}.insteadOf"),
|
||||
"https://github.com/acme/widgets",
|
||||
]);
|
||||
|
||||
let observation =
|
||||
observe_git_run_target(&workspace, Some("https://github.com/acme/widgets")).unwrap();
|
||||
let target = observation.run_target.as_ref().unwrap();
|
||||
|
||||
assert_eq!(target.sha, observation.legacy_git_context.sha);
|
||||
assert_eq!(bare_remote_branch_sha(&bare_origin, "feature"), target.sha);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_matching_tracking_ref_produces_a_branch_only_target() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let workspace = temp.path().join("workspace");
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
let bare_origin = init_bare_origin(temp.path());
|
||||
init_git_repo(&workspace, "feature", "https://github.com/acme/widgets");
|
||||
let local_url = format!("file://{}", bare_origin.display());
|
||||
run_git(&workspace, &[
|
||||
"config",
|
||||
&format!("url.{local_url}.insteadOf"),
|
||||
"https://github.com/acme/widgets",
|
||||
]);
|
||||
run_git(&workspace, &["push", "origin", "feature"]);
|
||||
run_git(&workspace, &[
|
||||
"-c",
|
||||
"user.name=test",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"--quiet",
|
||||
"-m",
|
||||
"local-only",
|
||||
]);
|
||||
mark_origin_branch_synced(&workspace, "feature");
|
||||
|
||||
let observation =
|
||||
observe_git_run_target(&workspace, Some("https://github.com/acme/widgets")).unwrap();
|
||||
let target = observation.run_target.as_ref().unwrap();
|
||||
|
||||
assert_eq!(target.sha, None);
|
||||
assert!(observation.legacy_git_context.sha.is_some());
|
||||
assert_ne!(
|
||||
bare_remote_branch_sha(&bare_origin, "feature"),
|
||||
observation.legacy_git_context.sha,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branches_that_are_invalid_run_selectors_do_not_produce_git_targets() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let invalid_branches = [
|
||||
"heads/topic",
|
||||
"tags/release",
|
||||
"0123456789abcdef0123456789abcdef01234567",
|
||||
];
|
||||
|
||||
for (index, branch) in invalid_branches.into_iter().enumerate() {
|
||||
let workspace = temp.path().join(format!("workspace-{index}"));
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
init_git_repo(&workspace, branch, "https://github.com/acme/widgets");
|
||||
|
||||
let observation = observe_git_run_target(&workspace, None).unwrap();
|
||||
|
||||
assert_eq!(observation.run_target, None, "branch {branch}");
|
||||
assert_eq!(observation.legacy_git_context.branch, branch);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_push_and_origin_mismatch_produce_branch_only_targets() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let failed_workspace = temp.path().join("failed");
|
||||
std::fs::create_dir_all(&failed_workspace).unwrap();
|
||||
init_git_repo(
|
||||
&failed_workspace,
|
||||
"feature",
|
||||
"https://github.com/acme/widgets",
|
||||
);
|
||||
let missing_url = format!("file://{}/missing.git", temp.path().display());
|
||||
run_git(&failed_workspace, &[
|
||||
"config",
|
||||
&format!("url.{missing_url}.insteadOf"),
|
||||
"https://github.com/acme/widgets",
|
||||
]);
|
||||
|
||||
let failed =
|
||||
observe_git_run_target(&failed_workspace, Some("https://github.com/acme/widgets"))
|
||||
.unwrap();
|
||||
assert_eq!(failed.run_target.as_ref().unwrap().sha, None);
|
||||
assert!(failed.legacy_git_context.sha.is_some());
|
||||
assert_eq!(failed.legacy_git_context.dirty, DirtyStatus::Clean);
|
||||
|
||||
let mismatched_workspace = temp.path().join("mismatched");
|
||||
std::fs::create_dir_all(&mismatched_workspace).unwrap();
|
||||
let bare_origin = init_bare_origin(&temp.path().join("other"));
|
||||
init_git_repo(
|
||||
&mismatched_workspace,
|
||||
"feature",
|
||||
bare_origin.to_str().unwrap(),
|
||||
);
|
||||
|
||||
let mismatched = observe_git_run_target(
|
||||
&mismatched_workspace,
|
||||
Some("https://github.com/acme/configured"),
|
||||
)
|
||||
.unwrap();
|
||||
let target = mismatched.run_target.as_ref().unwrap();
|
||||
assert_eq!(target.repo, "acme/configured");
|
||||
assert_eq!(target.sha, None);
|
||||
assert_eq!(bare_remote_branch_sha(&bare_origin, "feature"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_observation_distinguishes_dirty_unsupported_and_unusable_checkouts() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let workspace = temp.path().join("workspace");
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
let bare_origin = init_bare_origin(temp.path());
|
||||
init_git_repo(&workspace, "feature", bare_origin.to_str().unwrap());
|
||||
std::fs::write(workspace.join("dirty.txt"), "dirty").unwrap();
|
||||
|
||||
let unsupported = observe_git_run_target(&workspace, None).unwrap();
|
||||
assert!(unsupported.run_target.is_none());
|
||||
assert_eq!(unsupported.legacy_git_context.dirty, DirtyStatus::Dirty);
|
||||
assert_eq!(
|
||||
unsupported.legacy_git_context.origin_url,
|
||||
fabro_github::normalize_repo_origin_url(&bare_origin.to_string_lossy()),
|
||||
);
|
||||
|
||||
let not_repo = temp.path().join("not-repo");
|
||||
std::fs::create_dir_all(¬_repo).unwrap();
|
||||
assert!(observe_git_run_target(¬_repo, None).is_none());
|
||||
|
||||
let unborn = temp.path().join("unborn");
|
||||
std::fs::create_dir_all(&unborn).unwrap();
|
||||
run_git(&unborn, &["init", "--quiet"]);
|
||||
assert!(observe_git_run_target(&unborn, None).is_none());
|
||||
|
||||
run_git(&workspace, &["checkout", "--detach", "--quiet"]);
|
||||
assert!(observe_git_run_target(&workspace, None).is_none());
|
||||
}
|
||||
|
||||
fn init_git_repo(path: &Path, branch: &str, origin_url: &str) {
|
||||
run_git(path, &[
|
||||
"-c",
|
||||
|
|
|
|||
572
lib/components/fabro-manifest/src/local_workflow_package.rs
Normal file
572
lib/components/fabro-manifest/src/local_workflow_package.rs
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
#![expect(
|
||||
clippy::result_large_err,
|
||||
reason = "the public error contract preserves concrete config and collection source errors"
|
||||
)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use fabro_config::project::{WorkflowLocation, discover_project_config};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::workflow_version_collector::{
|
||||
canonicalize_location, collect_workflow_versions_at_location,
|
||||
};
|
||||
use crate::{CollectedWorkflowClosure, WorkflowVersionCollectError};
|
||||
|
||||
/// One local workflow resolved to a canonical package root and collected
|
||||
/// exactly once.
|
||||
#[derive(Debug)]
|
||||
pub struct ResolvedLocalWorkflowPackage {
|
||||
workflow_location: WorkflowLocation,
|
||||
source_root: PathBuf,
|
||||
closure: CollectedWorkflowClosure,
|
||||
}
|
||||
|
||||
/// Failure while resolving and collecting one local workflow package.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LocalWorkflowPackageError {
|
||||
#[error("failed to resolve local workflow `{workflow}`")]
|
||||
Resolve {
|
||||
workflow: PathBuf,
|
||||
#[source]
|
||||
source: fabro_config::Error,
|
||||
},
|
||||
#[error("failed to inspect a Git worktree for local workflow `{workflow}`")]
|
||||
Repository {
|
||||
workflow: PathBuf,
|
||||
#[source]
|
||||
source: git2::Error,
|
||||
},
|
||||
#[error("failed to canonicalize local workflow package path `{path}`")]
|
||||
Canonicalize {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("failed to collect local workflow package `{workflow}`")]
|
||||
Collect {
|
||||
workflow: PathBuf,
|
||||
#[source]
|
||||
source: WorkflowVersionCollectError,
|
||||
},
|
||||
}
|
||||
|
||||
impl ResolvedLocalWorkflowPackage {
|
||||
#[must_use]
|
||||
pub fn workflow_location(&self) -> &WorkflowLocation {
|
||||
&self.workflow_location
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn source_root(&self) -> &Path {
|
||||
&self.source_root
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn closure(&self) -> &CollectedWorkflowClosure {
|
||||
&self.closure
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve producer-readable workflow bytes under one stable local source
|
||||
/// root, then collect one immutable closure.
|
||||
///
|
||||
/// Named workflows prefer the current checkout's `.fabro/workflows` tree,
|
||||
/// preserve marked-project discovery, and use `user_workflows_root` only when
|
||||
/// it is explicitly supplied. Explicit paths use their containing Git
|
||||
/// worktree, the supplied user root, or their own containing directory, in
|
||||
/// that order. No ambient home or process-current-directory state is read.
|
||||
pub fn resolve_local_workflow_package(
|
||||
workflow: &Path,
|
||||
cwd: &Path,
|
||||
user_workflows_root: Option<&Path>,
|
||||
) -> Result<ResolvedLocalWorkflowPackage, LocalWorkflowPackageError> {
|
||||
let (location, source_root) = if is_workflow_name(workflow) {
|
||||
resolve_named_workflow(workflow, cwd, user_workflows_root)?
|
||||
} else {
|
||||
resolve_explicit_workflow(workflow, cwd, user_workflows_root)?
|
||||
};
|
||||
let source_root = canonicalize(&source_root)?;
|
||||
let workflow_location = canonicalize_location(location, |path, source| {
|
||||
LocalWorkflowPackageError::Canonicalize {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
let closure = collect_workflow_versions_at_location(&workflow_location, &source_root, workflow)
|
||||
.map_err(|source| LocalWorkflowPackageError::Collect {
|
||||
workflow: workflow.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
Ok(ResolvedLocalWorkflowPackage {
|
||||
workflow_location,
|
||||
source_root,
|
||||
closure,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_workflow_name(workflow: &Path) -> bool {
|
||||
workflow.extension().is_none()
|
||||
&& workflow
|
||||
.file_name()
|
||||
.is_some_and(|name| workflow.as_os_str() == name)
|
||||
}
|
||||
|
||||
fn resolve_named_workflow(
|
||||
workflow: &Path,
|
||||
cwd: &Path,
|
||||
user_workflows_root: Option<&Path>,
|
||||
) -> Result<(WorkflowLocation, PathBuf), LocalWorkflowPackageError> {
|
||||
let current_root = match worktree_root(cwd, workflow)? {
|
||||
Some(root) => root,
|
||||
None => canonicalize(cwd)?,
|
||||
};
|
||||
let project_candidate = current_root
|
||||
.join(".fabro/workflows")
|
||||
.join(workflow)
|
||||
.join("workflow.toml");
|
||||
if project_candidate.is_file() {
|
||||
return resolve_at(workflow, &project_candidate, current_root);
|
||||
}
|
||||
|
||||
let marked_project =
|
||||
discover_project_config(cwd).map_err(|source| LocalWorkflowPackageError::Resolve {
|
||||
workflow: workflow.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
if let Some(config) = marked_project {
|
||||
let fabro_root = config
|
||||
.parent()
|
||||
.expect("a discovered project config has a parent");
|
||||
let candidate = fabro_root
|
||||
.join("workflows")
|
||||
.join(workflow)
|
||||
.join("workflow.toml");
|
||||
if candidate.is_file() {
|
||||
let project_root = fabro_root
|
||||
.parent()
|
||||
.expect("the .fabro directory has a project parent");
|
||||
let source_root = match worktree_root(&candidate, workflow)? {
|
||||
Some(root) => root,
|
||||
None => canonicalize(project_root)?,
|
||||
};
|
||||
return resolve_at(workflow, &candidate, source_root);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(user_root) = user_workflows_root {
|
||||
let candidate = user_root.join(workflow).join("workflow.toml");
|
||||
if candidate.is_file() {
|
||||
return resolve_at(workflow, &candidate, canonicalize(user_root)?);
|
||||
}
|
||||
}
|
||||
|
||||
Err(LocalWorkflowPackageError::Resolve {
|
||||
workflow: workflow.to_path_buf(),
|
||||
source: fabro_config::Error::WorkflowNotFound(workflow.display().to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_explicit_workflow(
|
||||
workflow: &Path,
|
||||
cwd: &Path,
|
||||
user_workflows_root: Option<&Path>,
|
||||
) -> Result<(WorkflowLocation, PathBuf), LocalWorkflowPackageError> {
|
||||
let selected = if workflow.extension().is_none() {
|
||||
let path = if workflow.is_absolute() {
|
||||
workflow.to_path_buf()
|
||||
} else {
|
||||
cwd.join(workflow)
|
||||
};
|
||||
if path.is_dir() {
|
||||
path.join("workflow.toml")
|
||||
} else {
|
||||
return Err(LocalWorkflowPackageError::Resolve {
|
||||
workflow: workflow.to_path_buf(),
|
||||
source: fabro_config::Error::WorkflowNotFound(workflow.display().to_string()),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
workflow.to_path_buf()
|
||||
};
|
||||
let location = resolve_location(workflow, &selected, cwd)?;
|
||||
if let Some(root) = worktree_root(&location.graph, workflow)? {
|
||||
return Ok((location, root));
|
||||
}
|
||||
|
||||
if let Some(user_root) = user_workflows_root.filter(|root| root.exists()) {
|
||||
let canonical_user_root = canonicalize(user_root)?;
|
||||
let canonical_graph = canonicalize(&location.graph)?;
|
||||
if canonical_graph.starts_with(&canonical_user_root) {
|
||||
return Ok((location, canonical_user_root));
|
||||
}
|
||||
}
|
||||
|
||||
let source_root = location.dir.clone();
|
||||
Ok((location, source_root))
|
||||
}
|
||||
|
||||
fn resolve_at(
|
||||
workflow: &Path,
|
||||
selected: &Path,
|
||||
source_root: PathBuf,
|
||||
) -> Result<(WorkflowLocation, PathBuf), LocalWorkflowPackageError> {
|
||||
let selected = if selected.is_absolute() {
|
||||
selected.to_path_buf()
|
||||
} else {
|
||||
canonicalize(selected)?
|
||||
};
|
||||
let resolve_from = selected.parent().unwrap_or_else(|| Path::new("."));
|
||||
resolve_location(workflow, &selected, resolve_from).map(|location| (location, source_root))
|
||||
}
|
||||
|
||||
fn resolve_location(
|
||||
workflow: &Path,
|
||||
selected: &Path,
|
||||
cwd: &Path,
|
||||
) -> Result<WorkflowLocation, LocalWorkflowPackageError> {
|
||||
crate::resolve_existing_workflow_location(selected, cwd).map_err(|source| {
|
||||
LocalWorkflowPackageError::Resolve {
|
||||
workflow: workflow.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn worktree_root(
|
||||
path: &Path,
|
||||
workflow: &Path,
|
||||
) -> Result<Option<PathBuf>, LocalWorkflowPackageError> {
|
||||
let discover_from = if path.is_dir() {
|
||||
path
|
||||
} else {
|
||||
path.parent().unwrap_or(path)
|
||||
};
|
||||
let repository = match git2::Repository::discover(discover_from) {
|
||||
Ok(repository) => repository,
|
||||
Err(source) if source.code() == git2::ErrorCode::NotFound => return Ok(None),
|
||||
Err(source) => {
|
||||
return Err(LocalWorkflowPackageError::Repository {
|
||||
workflow: workflow.to_path_buf(),
|
||||
source,
|
||||
});
|
||||
}
|
||||
};
|
||||
if repository.is_bare() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(repository.workdir().map(Path::to_path_buf))
|
||||
}
|
||||
|
||||
fn canonicalize(path: &Path) -> Result<PathBuf, LocalWorkflowPackageError> {
|
||||
path.canonicalize()
|
||||
.map_err(|source| LocalWorkflowPackageError::Canonicalize {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "local-package tests build isolated filesystem and Git fixtures synchronously"
|
||||
)]
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use fabro_types::WorkflowVersion;
|
||||
use fabro_util::error::collect_chain;
|
||||
|
||||
use crate::{LocalWorkflowPackageError, resolve_local_workflow_package};
|
||||
|
||||
fn write(root: &Path, path: &str, content: &str) {
|
||||
let path = root.join(path);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, content).unwrap();
|
||||
}
|
||||
|
||||
fn write_workflow(root: &Path, directory: &str, graph: &str) -> PathBuf {
|
||||
write(
|
||||
root,
|
||||
&format!("{directory}/workflow.toml"),
|
||||
"_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n",
|
||||
);
|
||||
write(root, &format!("{directory}/workflow.fabro"), graph);
|
||||
root.join(directory).join("workflow.toml")
|
||||
}
|
||||
|
||||
fn init_repo(path: &Path) {
|
||||
git2::Repository::init(path).unwrap();
|
||||
}
|
||||
|
||||
fn canonical_versions(
|
||||
package: &crate::ResolvedLocalWorkflowPackage,
|
||||
) -> Vec<(fabro_types::WorkflowVersionId, Vec<u8>)> {
|
||||
package
|
||||
.closure()
|
||||
.versions()
|
||||
.map(|(id, version)| (id, version.version().canonical_bytes().unwrap()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn error_chain(error: &(dyn std::error::Error + 'static)) -> String {
|
||||
collect_chain(error).join(": ")
|
||||
}
|
||||
|
||||
fn root_version(package: &crate::ResolvedLocalWorkflowPackage) -> &WorkflowVersion {
|
||||
package.closure().versions().last().unwrap().1.version()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_markerless_project_workflow_precedes_explicit_user_root() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let project = temp.path().join("project");
|
||||
let user = temp.path().join("user-workflows");
|
||||
fs::create_dir_all(&project).unwrap();
|
||||
init_repo(&project);
|
||||
write_workflow(&project, ".fabro/workflows/hello", "digraph Project {}");
|
||||
write_workflow(&user, "hello", "digraph User {}");
|
||||
|
||||
let package =
|
||||
resolve_local_workflow_package(Path::new("hello"), &project, Some(&user)).unwrap();
|
||||
|
||||
assert_eq!(package.source_root(), project.canonicalize().unwrap());
|
||||
assert_eq!(
|
||||
package.workflow_location().graph,
|
||||
project
|
||||
.join(".fabro/workflows/hello/workflow.fabro")
|
||||
.canonicalize()
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
root_version(&package).entrypoint().as_str(),
|
||||
".fabro/workflows/hello/workflow.fabro",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_user_workflow_requires_and_uses_the_explicit_root() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let cwd = temp.path().join("cwd");
|
||||
let user = temp.path().join("user-workflows");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
write_workflow(&user, "hello", "digraph User {}");
|
||||
|
||||
let package =
|
||||
resolve_local_workflow_package(Path::new("hello"), &cwd, Some(&user)).unwrap();
|
||||
assert_eq!(package.source_root(), user.canonicalize().unwrap());
|
||||
assert_eq!(
|
||||
root_version(&package).entrypoint().as_str(),
|
||||
"hello/workflow.fabro",
|
||||
);
|
||||
|
||||
let error = resolve_local_workflow_package(Path::new("hello"), &cwd, None).unwrap_err();
|
||||
assert!(matches!(error, LocalWorkflowPackageError::Resolve { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_relative_directory_is_an_explicit_path_even_when_name_exists() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let project = temp.path().join("project");
|
||||
fs::create_dir_all(&project).unwrap();
|
||||
init_repo(&project);
|
||||
write_workflow(&project, ".fabro/workflows/hello", "digraph Named {}");
|
||||
write_workflow(&project, "hello", "digraph Explicit {}");
|
||||
|
||||
let package = resolve_local_workflow_package(Path::new("./hello"), &project, None).unwrap();
|
||||
|
||||
assert_eq!(package.source_root(), project.canonicalize().unwrap());
|
||||
assert_eq!(
|
||||
package.workflow_location().graph,
|
||||
project.join("hello/workflow.fabro").canonicalize().unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
root_version(&package).entrypoint().as_str(),
|
||||
"hello/workflow.fabro",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_workflow_uses_its_own_checkout_and_has_location_independent_bytes() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let caller = temp.path().join("caller");
|
||||
let first = temp.path().join("first");
|
||||
let second = temp.path().join("second");
|
||||
for root in [&caller, &first, &second] {
|
||||
fs::create_dir_all(root).unwrap();
|
||||
init_repo(root);
|
||||
}
|
||||
let first_workflow = write_workflow(
|
||||
&first,
|
||||
"flows/demo",
|
||||
"digraph Demo { task [prompt=\"@prompt.md\"] }",
|
||||
);
|
||||
write(&first, "flows/demo/prompt.md", "hello");
|
||||
let second_workflow = write_workflow(
|
||||
&second,
|
||||
"flows/demo",
|
||||
"digraph Demo { task [prompt=\"@prompt.md\"] }",
|
||||
);
|
||||
write(&second, "flows/demo/prompt.md", "hello");
|
||||
|
||||
let first_package = resolve_local_workflow_package(&first_workflow, &caller, None).unwrap();
|
||||
let second_package =
|
||||
resolve_local_workflow_package(&second_workflow, &caller, None).unwrap();
|
||||
|
||||
assert_eq!(first_package.source_root(), first.canonicalize().unwrap());
|
||||
assert_eq!(second_package.source_root(), second.canonicalize().unwrap());
|
||||
assert_eq!(
|
||||
canonical_versions(&first_package),
|
||||
canonical_versions(&second_package)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_in_a_checkout_allows_parent_segments_that_stay_inside_the_root() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let package_root = temp.path().join("package");
|
||||
fs::create_dir_all(&package_root).unwrap();
|
||||
init_repo(&package_root);
|
||||
let workflow = write_workflow(
|
||||
&package_root,
|
||||
"flows",
|
||||
"digraph Demo { task [prompt=\"@../shared.md\"] }",
|
||||
);
|
||||
write(&package_root, "shared.md", "shared");
|
||||
|
||||
let package = resolve_local_workflow_package(&workflow, temp.path(), None).unwrap();
|
||||
|
||||
assert_eq!(package.source_root(), package_root.canonicalize().unwrap());
|
||||
assert!(
|
||||
root_version(&package)
|
||||
.files()
|
||||
.keys()
|
||||
.any(|path| path.as_str() == "shared.md"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_workflow_uses_its_canonical_containing_directory() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let workflow = write_workflow(
|
||||
temp.path(),
|
||||
"loose",
|
||||
"digraph Demo { task [prompt=\"@prompt.md\"] }",
|
||||
);
|
||||
write(temp.path(), "loose/prompt.md", "hello");
|
||||
|
||||
let package = resolve_local_workflow_package(&workflow, temp.path(), None).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
package.source_root(),
|
||||
temp.path().join("loose").canonicalize().unwrap(),
|
||||
);
|
||||
assert!(
|
||||
root_version(&package)
|
||||
.files()
|
||||
.keys()
|
||||
.any(|path| path.as_str() == "prompt.md")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_direct_and_template_symlinks_that_escape_a_loose_package() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let outside = temp.path().join("outside");
|
||||
fs::create_dir_all(&outside).unwrap();
|
||||
write(&outside, "secret.md", "secret");
|
||||
|
||||
let direct = write_workflow(
|
||||
temp.path(),
|
||||
"direct",
|
||||
"digraph Demo { task [prompt=\"@secret.md\"] }",
|
||||
);
|
||||
symlink(
|
||||
outside.join("secret.md"),
|
||||
temp.path().join("direct/secret.md"),
|
||||
)
|
||||
.unwrap();
|
||||
let direct_error = resolve_local_workflow_package(&direct, temp.path(), None).unwrap_err();
|
||||
let direct_chain = error_chain(&direct_error);
|
||||
assert!(direct_chain.contains("secret.md"), "{direct_chain}");
|
||||
assert!(direct_chain.contains("direct"), "{direct_chain}");
|
||||
|
||||
let template = write_workflow(
|
||||
temp.path(),
|
||||
"template",
|
||||
"digraph Demo { task [prompt=\"@prompt.md\"] }",
|
||||
);
|
||||
write(
|
||||
temp.path(),
|
||||
"template/prompt.md",
|
||||
"{% include \"secret.md\" %}",
|
||||
);
|
||||
symlink(
|
||||
outside.join("secret.md"),
|
||||
temp.path().join("template/secret.md"),
|
||||
)
|
||||
.unwrap();
|
||||
let template_error =
|
||||
resolve_local_workflow_package(&template, temp.path(), None).unwrap_err();
|
||||
let template_chain = error_chain(&template_error);
|
||||
assert!(
|
||||
template_chain.contains("escapes template root"),
|
||||
"{template_chain}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_a_selected_workflow_symlink_outside_its_checkout() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let project = temp.path().join("project");
|
||||
let outside = temp.path().join("outside");
|
||||
fs::create_dir_all(project.join(".fabro/workflows/hello")).unwrap();
|
||||
fs::create_dir_all(&outside).unwrap();
|
||||
init_repo(&project);
|
||||
write(
|
||||
&project,
|
||||
".fabro/workflows/hello/workflow.toml",
|
||||
"_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n",
|
||||
);
|
||||
write(&outside, "workflow.fabro", "digraph Outside {}");
|
||||
symlink(
|
||||
outside.join("workflow.fabro"),
|
||||
project.join(".fabro/workflows/hello/workflow.fabro"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = resolve_local_workflow_package(Path::new("hello"), &project, None).unwrap_err();
|
||||
|
||||
assert!(matches!(error, LocalWorkflowPackageError::Collect { .. }));
|
||||
assert!(error_chain(&error).contains("escapes source root"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_and_missing_workflows_preserve_config_sources() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let malformed = temp.path().join("malformed/workflow.toml");
|
||||
write(temp.path(), "malformed/workflow.toml", "not valid = [");
|
||||
|
||||
let malformed_error =
|
||||
resolve_local_workflow_package(&malformed, temp.path(), None).unwrap_err();
|
||||
assert!(matches!(
|
||||
malformed_error,
|
||||
LocalWorkflowPackageError::Resolve { .. }
|
||||
));
|
||||
assert!(std::error::Error::source(&malformed_error).is_some());
|
||||
|
||||
let missing =
|
||||
resolve_local_workflow_package(Path::new("missing"), temp.path(), None).unwrap_err();
|
||||
assert!(matches!(missing, LocalWorkflowPackageError::Resolve { .. }));
|
||||
assert!(std::error::Error::source(&missing).is_some());
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ use std::collections::{BTreeSet, HashMap, HashSet};
|
|||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use anyhow::{Context as _, Result, anyhow, bail};
|
||||
use fabro_api::types;
|
||||
use fabro_config::project::WorkflowLocation;
|
||||
use fabro_config::{
|
||||
|
|
@ -20,7 +20,7 @@ use fabro_types::graph::ReferenceKind;
|
|||
use crate::{manifest_path_from_absolute, normalize_absolute_path};
|
||||
|
||||
pub(super) struct WorkflowBundler<'a> {
|
||||
cwd: &'a Path,
|
||||
package_root: &'a Path,
|
||||
inputs: &'a HashMap<String, toml::Value>,
|
||||
template_store: FilesystemTemplateStore,
|
||||
workflows: HashMap<String, CollectedWorkflowSource>,
|
||||
|
|
@ -39,11 +39,11 @@ pub(super) struct CollectedWorkflowSource {
|
|||
}
|
||||
|
||||
impl<'a> WorkflowBundler<'a> {
|
||||
pub(super) fn new(cwd: &'a Path, inputs: &'a HashMap<String, toml::Value>) -> Self {
|
||||
pub(super) fn new(package_root: &'a Path, inputs: &'a HashMap<String, toml::Value>) -> Self {
|
||||
Self {
|
||||
cwd,
|
||||
package_root,
|
||||
inputs,
|
||||
template_store: FilesystemTemplateStore::new(cwd),
|
||||
template_store: FilesystemTemplateStore::new(package_root),
|
||||
workflows: HashMap::new(),
|
||||
visited_workflows: HashSet::new(),
|
||||
workflow_version_projection: false,
|
||||
|
|
@ -55,7 +55,7 @@ impl<'a> WorkflowBundler<'a> {
|
|||
workflow: &Path,
|
||||
project_config: Option<(&ManifestPath, &str)>,
|
||||
) -> Result<HashMap<String, types::ManifestWorkflow>> {
|
||||
let root_key = self.collect_workflow_entry(workflow, self.cwd)?;
|
||||
let root_key = self.collect_workflow_entry(workflow, self.package_root)?;
|
||||
|
||||
if let Some((config_path, source)) = project_config {
|
||||
let mut root = self
|
||||
|
|
@ -89,19 +89,18 @@ impl<'a> WorkflowBundler<'a> {
|
|||
|
||||
/// Collects the workflow at `location` and returns its manifest key.
|
||||
fn collect_workflow_location(&mut self, location: &WorkflowLocation) -> Result<String> {
|
||||
let dot_path = manifest_path_from_absolute(&location.graph, self.cwd)?;
|
||||
let dot_path = manifest_path_from_absolute(&location.graph, self.package_root)?;
|
||||
let dot_key = dot_path.to_string();
|
||||
if !self.visited_workflows.insert(dot_key.clone()) {
|
||||
return Ok(dot_key);
|
||||
}
|
||||
|
||||
let source = std::fs::read_to_string(&location.graph)
|
||||
.with_context(|| format!("Failed to read {}", location.graph.display()))?;
|
||||
let source = self.read_package_file(&location.graph)?;
|
||||
let config = if let Some(workflow_toml_path) = location.toml.as_ref() {
|
||||
Some(types::ManifestWorkflowConfig {
|
||||
path: manifest_path_from_absolute(workflow_toml_path, self.cwd)?.to_string(),
|
||||
source: std::fs::read_to_string(workflow_toml_path)
|
||||
.with_context(|| format!("Failed to read {}", workflow_toml_path.display()))?,
|
||||
path: manifest_path_from_absolute(workflow_toml_path, self.package_root)?
|
||||
.to_string(),
|
||||
source: self.read_package_file(workflow_toml_path)?,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
|
|
@ -253,10 +252,7 @@ impl<'a> WorkflowBundler<'a> {
|
|||
|
||||
for imported in imports {
|
||||
if visited_imports.insert(imported.path.to_string()) {
|
||||
let imported_source = std::fs::read_to_string(&imported.absolute_path)
|
||||
.with_context(|| {
|
||||
format!("Failed to read {}", imported.absolute_path.display())
|
||||
})?;
|
||||
let imported_source = self.read_package_file(&imported.absolute_path)?;
|
||||
let imported_scan = WorkflowScanInput {
|
||||
absolute_dot_path: imported.absolute_path,
|
||||
dot_path: imported.path,
|
||||
|
|
@ -286,8 +282,7 @@ impl<'a> WorkflowBundler<'a> {
|
|||
bundled: &BundledFile,
|
||||
workflow_template_root: &ManifestPath,
|
||||
) -> Result<()> {
|
||||
let source = std::fs::read_to_string(&bundled.absolute_path)
|
||||
.with_context(|| format!("Failed to read {}", bundled.absolute_path.display()))?;
|
||||
let source = self.read_package_file(&bundled.absolute_path)?;
|
||||
let template_root = template_root_for_bundled_file(&bundled.path, workflow_template_root)?;
|
||||
self.collect_template_include_files(
|
||||
files,
|
||||
|
|
@ -375,7 +370,7 @@ impl<'a> WorkflowBundler<'a> {
|
|||
let layer = source
|
||||
.parse::<SettingsLayer>()
|
||||
.context("Failed to parse run config TOML")?;
|
||||
let absolute_config_path = self.cwd.join(config_path.as_path());
|
||||
let absolute_config_path = self.package_root.join(config_path.as_path());
|
||||
let base_dir = absolute_config_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
|
|
@ -460,11 +455,10 @@ impl<'a> WorkflowBundler<'a> {
|
|||
|
||||
let absolute_path = normalize_absolute_path(base_dir, reference)
|
||||
.ok_or_else(|| anyhow!("unsupported manifest reference: {reference}"))?;
|
||||
let path = manifest_path_from_absolute(&absolute_path, self.cwd)?;
|
||||
let path = manifest_path_from_absolute(&absolute_path, self.package_root)?;
|
||||
let key = path.to_string();
|
||||
if !files.contains_key(&key) {
|
||||
let content = std::fs::read_to_string(&absolute_path)
|
||||
.with_context(|| format!("Failed to read {}", absolute_path.display()))?;
|
||||
let content = self.read_package_file(&absolute_path)?;
|
||||
files.insert(key.clone(), types::ManifestFileEntry {
|
||||
content,
|
||||
ref_: types::ManifestFileRef {
|
||||
|
|
@ -480,6 +474,32 @@ impl<'a> WorkflowBundler<'a> {
|
|||
path,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_package_file(&self, path: &Path) -> Result<String> {
|
||||
if !self.workflow_version_projection {
|
||||
return std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read {}", path.display()));
|
||||
}
|
||||
let canonical = path.canonicalize().with_context(|| {
|
||||
format!(
|
||||
"failed to canonicalize workflow package file `{}`",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
if !canonical.starts_with(self.package_root) {
|
||||
bail!(
|
||||
"workflow package file `{}` escapes source root `{}`",
|
||||
path.display(),
|
||||
self.package_root.display()
|
||||
);
|
||||
}
|
||||
std::fs::read_to_string(&canonical).with_context(|| {
|
||||
format!(
|
||||
"failed to read workflow package file `{}`",
|
||||
canonical.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkflowScanInput {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ use std::collections::{BTreeMap, HashMap, HashSet};
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use fabro_api::types;
|
||||
use fabro_config::project::WorkflowLocation;
|
||||
use fabro_types::{
|
||||
WorkflowPath, WorkflowPathParseError, WorkflowVersion, WorkflowVersionId,
|
||||
WorkflowVersionShapeError,
|
||||
|
|
@ -83,11 +84,67 @@ pub fn collect_workflow_versions(
|
|||
) -> Result<CollectedWorkflowClosure, WorkflowVersionCollectError> {
|
||||
let repository_workflow = repository_workflow_path(workflow);
|
||||
let location = crate::resolve_existing_workflow_location(&repository_workflow, checkout_root)
|
||||
.map_err(|source| location_error(workflow, source))?;
|
||||
.map_err(|source| match source {
|
||||
fabro_config::Error::WorkflowNotFound(_) => WorkflowVersionCollectError::WorkflowNotFound {
|
||||
path: workflow.to_path_buf(),
|
||||
},
|
||||
source => WorkflowVersionCollectError::Collect {
|
||||
path: workflow.to_path_buf(),
|
||||
source: source.into(),
|
||||
},
|
||||
})?;
|
||||
|
||||
let package_root =
|
||||
checkout_root
|
||||
.canonicalize()
|
||||
.map_err(|source| WorkflowVersionCollectError::Collect {
|
||||
path: workflow.to_path_buf(),
|
||||
source: anyhow::Error::new(source).context(format!(
|
||||
"failed to canonicalize workflow package root {}",
|
||||
checkout_root.display()
|
||||
)),
|
||||
})?;
|
||||
let location = canonicalize_location(location, |path, source| {
|
||||
WorkflowVersionCollectError::Collect {
|
||||
path: workflow.to_path_buf(),
|
||||
source: anyhow::Error::new(source).context(format!(
|
||||
"failed to canonicalize workflow path {}",
|
||||
path.display()
|
||||
)),
|
||||
}
|
||||
})?;
|
||||
collect_workflow_versions_at_location(&location, &package_root, workflow)
|
||||
}
|
||||
|
||||
/// Canonicalize a resolved workflow location so its paths compare against a
|
||||
/// canonical package root. `map_err` receives the path that failed.
|
||||
pub(super) fn canonicalize_location<E>(
|
||||
location: WorkflowLocation,
|
||||
map_err: impl Fn(&Path, std::io::Error) -> E,
|
||||
) -> Result<WorkflowLocation, E> {
|
||||
let canonicalize = |path: &Path| path.canonicalize().map_err(|source| map_err(path, source));
|
||||
let graph = canonicalize(&location.graph)?;
|
||||
let toml = location.toml.as_deref().map(canonicalize).transpose()?;
|
||||
let dir = graph
|
||||
.parent()
|
||||
.expect("a canonical workflow graph has a parent")
|
||||
.to_path_buf();
|
||||
Ok(WorkflowLocation {
|
||||
dir,
|
||||
graph,
|
||||
toml,
|
||||
slug: location.slug,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn collect_workflow_versions_at_location(
|
||||
location: &WorkflowLocation,
|
||||
package_root: &Path,
|
||||
workflow: &Path,
|
||||
) -> Result<CollectedWorkflowClosure, WorkflowVersionCollectError> {
|
||||
let inputs = HashMap::new();
|
||||
let collected = WorkflowBundler::new(checkout_root, &inputs)
|
||||
.collect_versions(&location)
|
||||
let collected = WorkflowBundler::new(package_root, &inputs)
|
||||
.collect_versions(location)
|
||||
.map_err(|source| WorkflowVersionCollectError::Collect {
|
||||
path: workflow.to_path_buf(),
|
||||
source,
|
||||
|
|
@ -105,22 +162,6 @@ fn repository_workflow_path(workflow: &Path) -> PathBuf {
|
|||
}
|
||||
}
|
||||
|
||||
fn location_error(workflow: &Path, source: anyhow::Error) -> WorkflowVersionCollectError {
|
||||
if matches!(
|
||||
source.downcast_ref::<fabro_config::Error>(),
|
||||
Some(fabro_config::Error::WorkflowNotFound(_))
|
||||
) {
|
||||
WorkflowVersionCollectError::WorkflowNotFound {
|
||||
path: workflow.to_path_buf(),
|
||||
}
|
||||
} else {
|
||||
WorkflowVersionCollectError::Collect {
|
||||
path: workflow.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VersionAssembler {
|
||||
root_key: String,
|
||||
/// Sources still waiting to be assembled; each is removed once visited.
|
||||
|
|
|
|||
|
|
@ -234,6 +234,40 @@ pub fn push_branch_noninteractive(repo: &Path, remote: &str, branch: &str) -> Re
|
|||
)
|
||||
}
|
||||
|
||||
/// Read the exact commit currently advertised for a remote branch without
|
||||
/// allowing Git to prompt for credentials.
|
||||
///
|
||||
/// This queries the remote itself rather than trusting the checkout's local
|
||||
/// remote-tracking ref, which may be stale or may have been rewritten locally.
|
||||
pub fn remote_branch_sha_noninteractive(
|
||||
repo: &Path,
|
||||
remote: &str,
|
||||
branch: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let branch_ref = format!("refs/heads/{branch}");
|
||||
let output = git_cmd(repo)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.args(["ls-remote", "--refs", remote, &branch_ref])
|
||||
.output()
|
||||
.map_err(|e| Error::engine_with_source("git ls-remote failed", e))?;
|
||||
if !output.status.success() {
|
||||
return Err(git_error("git ls-remote failed"));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
let mut fields = line.split_whitespace();
|
||||
let (Some(sha), Some(observed_ref), None) = (fields.next(), fields.next(), fields.next())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if observed_ref == branch_ref {
|
||||
return Ok(Some(sha.to_owned()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Push run and metadata branches to origin if a remote tracking branch exists.
|
||||
///
|
||||
/// Callers supply pre-built refspecs so they control force-push (`+` prefix).
|
||||
|
|
|
|||
|
|
@ -61,6 +61,11 @@ impl WorkflowBundle {
|
|||
pub fn workflows(&self) -> &HashMap<ManifestPath, BundledWorkflow> {
|
||||
&self.workflows
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn into_workflows(self) -> HashMap<ManifestPath, BundledWorkflow> {
|
||||
self.workflows
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use fabro_agent::Sandbox;
|
|||
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
|
||||
use fabro_types::{RunEvent, WorkflowSettings, fixtures};
|
||||
use fabro_workflow::event::Emitter;
|
||||
use fabro_workflow::git::{branch_needs_push, push_branch, push_ref};
|
||||
use fabro_workflow::git;
|
||||
use fabro_workflow::handler::HandlerRegistry;
|
||||
use fabro_workflow::handler::exit::ExitHandler;
|
||||
use fabro_workflow::handler::start::StartHandler;
|
||||
|
|
@ -178,7 +178,7 @@ fn push_ref_to_bare_remote() {
|
|||
|
||||
rename_branch(&repo_dir, "test-push");
|
||||
let url = format!("file://{}", remote_dir.display());
|
||||
push_ref(&repo_dir, &url, "refs/heads/test-push").unwrap();
|
||||
git::push_ref(&repo_dir, &url, "refs/heads/test-push").unwrap();
|
||||
|
||||
assert!(list_branch(&remote_dir, "test-push").contains("test-push"));
|
||||
}
|
||||
|
|
@ -194,7 +194,7 @@ fn push_branch_to_remote() {
|
|||
add_origin(&repo_dir, &remote_dir);
|
||||
rename_branch(&repo_dir, "main");
|
||||
|
||||
push_branch(&repo_dir, "origin", "main").unwrap();
|
||||
git::push_branch(&repo_dir, "origin", "main").unwrap();
|
||||
|
||||
assert!(list_branch(&remote_dir, "main").contains("main"));
|
||||
}
|
||||
|
|
@ -210,10 +210,10 @@ fn branch_needs_push_when_ahead() {
|
|||
add_origin(&repo_dir, &remote_dir);
|
||||
rename_branch(&repo_dir, "main");
|
||||
|
||||
push_branch(&repo_dir, "origin", "main").unwrap();
|
||||
git::push_branch(&repo_dir, "origin", "main").unwrap();
|
||||
empty_commit(&repo_dir, "second");
|
||||
|
||||
assert!(branch_needs_push(&repo_dir, "origin", "main"));
|
||||
assert!(git::branch_needs_push(&repo_dir, "origin", "main"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -227,9 +227,39 @@ fn branch_needs_push_when_in_sync() {
|
|||
add_origin(&repo_dir, &remote_dir);
|
||||
rename_branch(&repo_dir, "main");
|
||||
|
||||
push_branch(&repo_dir, "origin", "main").unwrap();
|
||||
git::push_branch(&repo_dir, "origin", "main").unwrap();
|
||||
|
||||
assert!(!branch_needs_push(&repo_dir, "origin", "main"));
|
||||
assert!(!git::branch_needs_push(&repo_dir, "origin", "main"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_branch_sha_ignores_a_locally_rewritten_tracking_ref() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let repo_dir = dir.path().join("repo");
|
||||
let remote_dir = dir.path().join("remote.git");
|
||||
|
||||
init_bare_remote(&remote_dir);
|
||||
init_repo(&repo_dir);
|
||||
add_origin(&repo_dir, &remote_dir);
|
||||
rename_branch(&repo_dir, "main");
|
||||
git::push_branch(&repo_dir, "origin", "main").unwrap();
|
||||
let remote_sha = git::head_sha(&repo_dir).unwrap();
|
||||
|
||||
empty_commit(&repo_dir, "local-only");
|
||||
let local_sha = git::head_sha(&repo_dir).unwrap();
|
||||
let update_tracking = Command::new("git")
|
||||
.args(["update-ref", "refs/remotes/origin/main", "HEAD"])
|
||||
.current_dir(&repo_dir)
|
||||
.output()
|
||||
.expect("git update-ref should run");
|
||||
assert_success(&update_tracking, "git update-ref");
|
||||
assert!(!git::branch_needs_push(&repo_dir, "origin", "main"));
|
||||
|
||||
assert_eq!(
|
||||
git::remote_branch_sha_noninteractive(&repo_dir, "origin", "main").unwrap(),
|
||||
Some(remote_sha.clone()),
|
||||
);
|
||||
assert_ne!(local_sha, remote_sha);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue