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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).

### Added

- `forge drift --target <BASE>` verifies a module's assembled `build/` against where it was deployed, scoped to the module's own files. It mirrors `forge install --target`: each provider's `build/<provider>` is compared to `<BASE>/<provider-target>`, so files built but not yet deployed surface as local-only, deployment edits surface as frontmatter/body drift, and this module's deployed files (per the target `.manifest` plus provenance attribution) that are no longer built surface as drift. Unlike `--upstream` against a multi-module tree such as `~/.claude`, other modules' files are never reported. `--target` and `--upstream` are mutually exclusive. (#61)
- `forge validate` sanity-checks Claude Code plugin scaffolding when `.claude-plugin/plugin.json` is present: each manifest (`plugin.json`, `.claude-plugin/marketplace.json`, `hooks/hooks.json`) must be valid JSON, and every hook script referenced via `${CLAUDE_PLUGIN_ROOT}` must exist and be executable (the most common cause of hooks silently not firing). It deliberately makes no assertions about the plugin or marketplace field schema, so it does not break when Claude Code's plugin format changes. Non-plugin modules are unaffected: the check runs only when `plugin.json` exists. (#59)
- `.forge` consumer manifests accept `git:` sources pinned to a 40-hex commit SHA. `forge install` clones the remote via `gix` into a content-addressed cache at `~/.cache/forge/git/<host>/<owner>/<repo>/`, materializes the pinned tree, and feeds it through the standard assemble + deploy pipeline. HTTPS-only; `ssh://`, `git://`, `git@host:` shorthand, and userinfo URLs are rejected at parse time. Branch names, tags, and abbreviated SHAs are rejected in favor of explicit 40-char commit hashes. Cache hits are instant; the bare clone is reused across SHA pins within the same repository. (#53)

Expand Down
6 changes: 4 additions & 2 deletions src/cli/deploy/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,9 @@ fn validate_target_boundary(target_path: &Path, base_directory: &Path) -> Result
}

/// Load the previously deployed `.manifest` from a provider's target directory.
fn load_deployed_manifest(target_base: &Path) -> HashMap<String, manifest::ManifestEntry> {
pub(crate) fn load_deployed_manifest(
target_base: &Path,
) -> HashMap<String, manifest::ManifestEntry> {
let manifest_path = target_base.join(".manifest");
let Ok(content) = fs::read_to_string(&manifest_path) else {
return HashMap::new();
Expand Down Expand Up @@ -517,7 +519,7 @@ fn collect_files_recursive(dir: &Path) -> Result<Vec<std::path::PathBuf>, Error>
/// inputs fail to parse as URLs. This prevents two modules named `forge-core`
/// at different repositories from incorrectly pruning each other's deployed
/// files, and stops `Prompts` from matching `PublishPrompts` via a substring.
fn is_owned_by_module(
pub(crate) fn is_owned_by_module(
entry: &manifest::ManifestEntry,
target_base: &Path,
module_name: Option<&str>,
Expand Down
27 changes: 27 additions & 0 deletions src/cli/drift/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::fs;
use std::path::Path;

mod scope;

const BODY_KEY: &str = "body";

// --- Types ---
Expand Down Expand Up @@ -44,7 +46,32 @@ pub struct DriftResult {

// --- Execution ---

/// Route to upstream comparison (`--upstream`) or manifest-scoped deployment
/// verification (`--target`). Exactly one of the two must be provided.
pub fn execute(
module_path: &str,
upstream_path: Option<&str>,
target_path: Option<&str>,
ignore_keys: &[String],
json_output: bool,
) -> Result<i32, Error> {
match (upstream_path, target_path) {
(Some(upstream), None) => {
execute_upstream(module_path, upstream, ignore_keys, json_output)
}
(None, Some(target)) => scope::execute(module_path, target, ignore_keys, json_output),
(Some(_), Some(_)) => Err(Error::new(
ErrorKind::Config,
"--upstream and --target are mutually exclusive".to_string(),
)),
(None, None) => Err(Error::new(
ErrorKind::Config,
"provide --upstream <DIR> (compare two module trees) or --target <DIR> (verify build against a deployment)".to_string(),
)),
}
}

fn execute_upstream(
module_path: &str,
upstream_path: &str,
ignore_keys: &[String],
Expand Down
190 changes: 190 additions & 0 deletions src/cli/drift/scope.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
//! Manifest-scoped drift verification: compare a module's assembled `build/`
//! against where it was deployed, limited to this module's own files.
//!
//! `forge drift --upstream <DIR>` compares two module trees by name and, run
//! against a multi-module deployment like `~/.claude`, floods the report with
//! every other module's files as "upstream only". This mode instead walks
//! `build/<provider>` (this module's output) and compares each file to its
//! deployed counterpart, then consults the deployed `.manifest` + provenance
//! attribution to flag this module's deployed files that are no longer built.

use commands::error::{Error, ErrorKind};
use std::collections::{BTreeMap, HashSet};
use std::fs;
use std::path::Path;

use super::{DriftEntry, DriftResult, DriftStatus, compare_file_content, print_drift_result};
use crate::cli::config;
use crate::cli::deploy::{is_owned_by_module, load_deployed_manifest};

const CONTENT_PREFIXES: [&str; 3] = ["agents/", "skills/", "rules/"];

/// Verify each provider's `build/<provider>` against `<target_base>/<provider
/// target>` (mirroring `forge install --target`), scoped to this module.
pub fn execute(
source: &str,
target_base: &str,
ignore: &[String],
json_output: bool,
) -> Result<i32, Error> {
let module_root = Path::new(source);
if !module_root.join("module.yaml").is_file() {
return Err(Error::new(
ErrorKind::Io,
format!(
"{source} is not a module root (no module.yaml); --target verify compares its build/ against a deployment"
),
));
}

let source_uri = config::load_source_uri(module_root);
let module_name = (!source_uri.is_empty()).then_some(source_uri.as_str());

let merged_config = config::load_merged_config(module_root)?;
let providers = config::load_providers(&merged_config)?;

let ignored: HashSet<&str> = ignore.iter().map(String::as_str).collect();
let base = Path::new(target_base);
let mut result = DriftResult::default();
let mut compared_any = false;

for (provider_name, provider_config) in &providers {
let build_dir = module_root.join("build").join(provider_name);
if !build_dir.is_dir() {
continue;
}
compared_any = true;
compare_provider(
&mut result,
&build_dir,
&base.join(&provider_config.target),
provider_name,
module_name,
&ignored,
);
}

if !compared_any {
return Err(Error::new(
ErrorKind::Io,
format!(
"no build/ output under {source}; run `forge assemble` or `forge install` first"
),
));
}

if json_output {
match serde_json::to_string_pretty(&result) {
Ok(json) => println!("{json}"),
Err(error) => eprintln!("failed to serialize drift result: {error}"),
}
} else {
print_drift_result(&result);
}

let has_drift = result.entries.iter().any(|entry| {
matches!(
entry.status,
DriftStatus::FrontmatterOnly
| DriftStatus::BodyOnly
| DriftStatus::Both
| DriftStatus::UpstreamOnly
)
});
Ok(i32::from(has_drift))
}

fn compare_provider(
result: &mut DriftResult,
build_dir: &Path,
deployed_base: &Path,
provider_name: &str,
module_name: Option<&str>,
ignored: &HashSet<&str>,
) {
let build_files = collect_content_files(build_dir);

for (relative, build_content) in &build_files {
let deployed_path = deployed_base.join(relative);
match fs::read_to_string(&deployed_path) {
Ok(deployed_content) => {
result.entries.push(compare_file_content(
relative,
build_content,
&deployed_content,
provider_name,
ignored,
));
}
Err(_) => {
result
.entries
.push(only_entry(relative, DriftStatus::LocalOnly, provider_name));
}
}
}

// This module's deployed files (per the target manifest + provenance) that
// are no longer built — stale deployments that should be pruned.
for (key, entry) in load_deployed_manifest(deployed_base) {
if build_files.contains_key(&key) || !is_content_key(&key) {
continue;
}
if is_owned_by_module(&entry, deployed_base, module_name) {
result
.entries
.push(only_entry(&key, DriftStatus::UpstreamOnly, provider_name));
}
}
}

fn only_entry(name: &str, status: DriftStatus, category: &str) -> DriftEntry {
DriftEntry {
name: name.to_string(),
status,
category: category.to_string(),
changed_keys: Vec::new(),
renamed_from: None,
source_uri: None,
}
}

fn is_content_key(key: &str) -> bool {
CONTENT_PREFIXES
.iter()
.any(|prefix| key.starts_with(prefix))
}

/// Collect content files under a provider build directory, keyed by their path
/// relative to it. Sidecar directories, `.manifest`, and dotfiles are skipped.
fn collect_content_files(build_dir: &Path) -> BTreeMap<String, String> {
let mut files = BTreeMap::new();
collect_recursive(build_dir, build_dir, &mut files);
files
}

fn collect_recursive(base: &Path, current: &Path, files: &mut BTreeMap<String, String>) {
let Ok(entries) = fs::read_dir(current) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = path.file_name().unwrap_or_default().to_string_lossy();
if name.starts_with('.') {
continue;
}
if path.is_dir() {
collect_recursive(base, &path, files);
} else if let Ok(content) = fs::read_to_string(&path) {
let relative = path
.strip_prefix(base)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
files.insert(relative, content);
}
}
}

#[cfg(test)]
mod tests;
Loading