migrate: remove patch system, adopt direct source ownership

BREAKING CHANGE: The patch-based build system is removed.
All Red Bear source now lives in local/sources/<component>/ as git repos.

Changes:
- src/recipe.rs: remove patches field from SourceRecipe::Git/Tar, add Local variant
- src/cook/fetch.rs: delete fetch_apply_patches, validate_patches, normalize_patch,
  fetch_compute_patches_hash, fetch_write_patches_state, fetch_patches_state_stale,
  fetch_validate_patch_symlinks, fetch_is_patches_newer. Simplify fetch and
  fetch_offline. Remove recipe_has_patches. Add Local source handler.
- src/bin/repo.rs: remove validate-patches command and handle_validate_patches
- 70 recipe.toml files: remove patches arrays, convert core recipes to Local source
- 272 .patch symlinks deleted from recipe directories
- integrate-redbear.sh: replace patch symlink logic with source fork validation
- Makefile: replace validate-patches with validate-sources target
- AGENTS.md: remove 369 lines of patch documentation, add source ownership model
- local/docs/PATCH-GOVERNANCE.md: deleted (replaced by SOURCE-OWNERSHIP-MODEL.md)
- local/docs/SOURCE-OWNERSHIP-MODEL.md: new canonical reference
- local/sources/: Red Bear fork repos created (kernel, relibc, base, bootloader,
  installer) from frozen 0.1.0 pre-patched archives
- .gitignore: exclude local/sources/ (separate git repos)
- create-forks.sh: new script for initializing fork repos

Build: cargo check passes (5 warnings, 0 errors).
Developer workflow is now: edit local/sources/ → repo cook → test. No patches.
This commit is contained in:
2026-05-29 22:32:36 +03:00
parent 289d53d62d
commit a23012cee0
175 changed files with 204 additions and 1722 deletions
+1 -28
View File
@@ -2,7 +2,7 @@ use ansi_to_tui::IntoText;
use anyhow::{Context, anyhow, bail};
use cookbook::config::{CookConfig, get_config, init_config};
use cookbook::cook::cook_build::{build, get_stage_dirs, remove_stage_dir};
use cookbook::cook::fetch::{FetchResult, fetch, fetch_offline, validate_patches};
use cookbook::cook::fetch::{FetchResult, fetch, fetch_offline};
use cookbook::cook::fs::{create_target_dir, run_command};
use cookbook::cook::ident;
use cookbook::cook::package::{package, package_handle_push};
@@ -122,7 +122,6 @@ enum CliCommand {
Push,
PushTree,
Find,
ValidatePatches,
}
impl CliCommand {
@@ -156,7 +155,6 @@ impl FromStr for CliCommand {
"push-tree" => Ok(CliCommand::PushTree),
"cook-tree" => Ok(CliCommand::CookTree),
"find" => Ok(CliCommand::Find),
"validate-patches" => Ok(CliCommand::ValidatePatches),
_ => Err(anyhow!("Unknown command '{}'\n{}\n", s, REPO_HELP_STR)),
}
}
@@ -174,7 +172,6 @@ impl ToString for CliCommand {
CliCommand::PushTree => "push-tree".to_string(),
CliCommand::CookTree => "cook-tree".to_string(),
CliCommand::Find => "find".to_string(),
CliCommand::ValidatePatches => "validate-patches".to_string(),
}
}
}
@@ -419,26 +416,6 @@ fn repo_inner(
println!("{}", recipe.dir.display());
false
}
CliCommand::ValidatePatches => {
let validate_fn = move |logger: &PtyOut| -> Result<bool, anyhow::Error> {
handle_validate_patches(recipe, logger)?;
Ok(false)
};
let Some(_log_path) = &config.logs_dir else {
return validate_fn(&None);
};
let (status_tx, status_rx) = mpsc::channel::<StatusUpdate>();
let (mut stdout_writer, mut stderr_writer) = setup_logger(&status_tx, &recipe.name);
let mut logger = Some((&mut stdout_writer, &mut stderr_writer));
let result = validate_fn(&logger);
if let Err(ref e) = result {
write_to_pty(&logger, &format!("\n{:?}", e));
}
flush_pty(&mut logger);
drop(status_tx);
let _ = status_rx.recv_timeout(std::time::Duration::from_millis(100));
result?
}
})
}
@@ -812,10 +789,6 @@ fn handle_cook(
Ok(build_result.cached)
}
fn handle_validate_patches(recipe: &CookRecipe, logger: &PtyOut) -> anyhow::Result<()> {
validate_patches(recipe, logger).map_err(|e| anyhow!("{}", e))
}
/// delete stage artifacts upon nonstop failure to let repo_builder know
fn handle_nonstop_fail(recipe: &CookRecipe) -> cookbook::Result<()> {
let target_dir = recipe.target_dir();
+34 -673
View File
@@ -91,29 +91,8 @@ fn redbear_allow_protected_fetch() -> bool {
)
}
/// Check if a recipe has patches that would be at risk from upstream source changes.
/// Recipes with patches should be protected from online re-fetching because:
/// 1. Upstream source changes can break patch context lines
/// 2. The atomic patch system expects patches to apply cleanly against the frozen source
/// 3. Re-fetching from upstream could pull incompatible changes that invalidate all patches
fn recipe_has_patches(recipe: &CookRecipe) -> bool {
match &recipe.recipe.source {
Some(SourceRecipe::Git { patches, .. }) => !patches.is_empty(),
Some(SourceRecipe::Tar { patches, .. }) => !patches.is_empty(),
_ => false,
}
}
/// Check if a recipe should be protected from online re-fetching.
/// A recipe is protected if:
/// 1. It's on the explicit protected list (redbear_protected_recipe), OR
/// 2. It has patches that would be at risk from upstream source changes
///
/// This ensures that ANY recipe carrying patches — whether explicitly listed or not —
/// is automatically shielded from accidental upstream overwrites. The explicit list
/// covers recipes that need protection even without patches (e.g., custom source recipes).
fn redbear_should_protect(recipe: &CookRecipe) -> bool {
redbear_protected_recipe(recipe.name.name()) || recipe_has_patches(recipe)
redbear_protected_recipe(recipe.name.name())
}
fn redbear_release() -> Option<String> {
@@ -287,12 +266,26 @@ pub fn fetch_offline(recipe: &CookRecipe, logger: &PtyOut) -> Result<FetchResult
fetch_make_symlink(&source_dir, &same_as)?;
r
}
Some(SourceRecipe::Local { path }) => {
let source_path = recipe_dir.join(path);
if !source_path.exists() {
bail_other_err!("local source not found: {}", source_path.display());
}
if source_dir.is_symlink() || !source_dir.exists() {
let _ = fs::remove_file(&source_dir);
symlink(&source_path, &source_dir)
.map_err(|e| format!("failed to symlink local source: {e}"))?;
}
let ident = get_git_head_rev(&source_path)
.map(|(r, _)| r)
.unwrap_or_else(|_| "local".to_string());
FetchResult::cached(source_dir, ident)
}
Some(SourceRecipe::Git {
git: _,
upstream: _,
branch: _,
rev,
patches,
script,
shallow_clone: _,
}) => {
@@ -322,43 +315,12 @@ pub fn fetch_offline(recipe: &CookRecipe, logger: &PtyOut) -> Result<FetchResult
);
}
}
// Validate all patch symlinks resolve before touching source.
fetch_validate_patch_symlinks(recipe_dir, patches)?;
if (!patches.is_empty() || script.is_some())
&& fetch_patches_state_stale(recipe_dir, patches, script, &source_dir)
{
log_to_pty!(
logger,
"[INFO] patches state stale or missing — re-applying"
);
// Reset source to clean state, including submodules.
let mut clean_cmd = Command::new("git");
clean_cmd.arg("-C").arg(&source_dir);
clean_cmd.arg("clean").arg("-ffdx");
let _ = run_command(clean_cmd, logger);
let mut reset_cmd = Command::new("git");
reset_cmd.arg("-C").arg(&source_dir);
reset_cmd.arg("reset").arg("--hard");
run_command(reset_cmd, logger)?;
// Recursively reset submodules if any exist.
if source_dir.join(".gitmodules").exists() {
let mut sub_cmd = Command::new("git");
sub_cmd.arg("-C").arg(&source_dir);
sub_cmd.arg("submodule").arg("foreach");
sub_cmd.arg("--recursive");
sub_cmd.arg("git reset --hard && git clean -ffdx");
run_command(sub_cmd, logger)?;
}
fetch_apply_patches(recipe_dir, patches, script, &source_dir, logger)?;
}
FetchResult::cached(source_dir, head_rev)
}
}
Some(SourceRecipe::Tar {
tar: _,
blake3,
patches,
script,
}) => {
let ident = blake3.clone().unwrap_or("no_tar_blake3_hash_info".into());
@@ -375,9 +337,7 @@ pub fn fetch_offline(recipe: &CookRecipe, logger: &PtyOut) -> Result<FetchResult
}
create_dir(&source_dir)?;
fetch_extract_tar(source_tar, &source_dir, logger)?;
fetch_apply_patches(recipe_dir, patches, script, &source_dir, logger)?;
} else {
// need to trust this tar file
bail_other_err!(
"Please add blake3 = {source_tar_blake3:?} to {recipe:?}",
recipe = recipe_dir.join("recipe.toml").display(),
@@ -397,30 +357,14 @@ pub fn fetch_offline(recipe: &CookRecipe, logger: &PtyOut) -> Result<FetchResult
pub fn fetch(recipe: &CookRecipe, check_source: bool, logger: &PtyOut) -> Result<FetchResult> {
if redbear_should_protect(recipe) && !redbear_allow_protected_fetch() {
let reason = if redbear_protected_recipe(recipe.name.name()) {
"explicitly protected"
} else {
"has patches (auto-protected)"
};
log_to_pty!(
logger,
"[INFO]: {} recipe {} uses local source (fetch disabled; use --allow-protected flag or set REDBEAR_ALLOW_PROTECTED_FETCH=1 to override)",
reason,
"[INFO]: protected recipe {} (fetch disabled; use --allow-protected flag or set REDBEAR_ALLOW_PROTECTED_FETCH=1 to override)",
recipe.name.name()
);
return fetch_offline(recipe, logger);
}
// Warn when --allow-protected bypasses protection on a patched recipe.
// Upstream source changes may break patch context lines.
if redbear_allow_protected_fetch() && recipe_has_patches(recipe) {
log_to_pty!(
logger,
"[WARN]: recipe {} has patches but --allow-protected is set — upstream source changes may break patches",
recipe.name.name()
);
}
let recipe_dir = &recipe.dir;
let source_dir = recipe_dir.join("source");
match recipe.recipe.build.kind {
@@ -461,12 +405,26 @@ pub fn fetch(recipe: &CookRecipe, check_source: bool, logger: &PtyOut) -> Result
}
FetchResult::new(source_dir, "local_source".to_string(), cached)
}
Some(SourceRecipe::Local { path }) => {
let source_path = recipe_dir.join(path);
if !source_path.exists() {
bail_other_err!("local source not found: {}", source_path.display());
}
if source_dir.is_symlink() || !source_dir.exists() {
let _ = fs::remove_file(&source_dir);
symlink(&source_path, &source_dir)
.map_err(|e| format!("failed to symlink local source: {e}"))?;
}
let ident = get_git_head_rev(&source_path)
.map(|(r, _)| r)
.unwrap_or_else(|_| "local".to_string());
FetchResult::cached(source_dir, ident)
}
Some(SourceRecipe::Git {
git,
upstream,
branch,
rev,
patches,
script,
shallow_clone,
}) => {
@@ -575,32 +533,6 @@ pub fn fetch(recipe: &CookRecipe, check_source: bool, logger: &PtyOut) -> Result
if !cached {
if let Some(_upstream) = upstream {
//TODO: set upstream URL (is this needed?)
// git remote set-url upstream "$GIT_UPSTREAM" &> /dev/null ||
// git remote add upstream "$GIT_UPSTREAM"
// git fetch upstream
}
if !patches.is_empty() || script.is_some() {
if is_local_overlay(recipe_dir) && !redbear_allow_protected_fetch() {
log_to_pty!(
logger,
"[WARN] skipping git reset --hard for local overlay recipe at {} \
(set REDBEAR_ALLOW_PROTECTED_FETCH=1 to override)",
recipe_dir.display()
);
} else {
let mut clean_cmd = Command::new("git");
clean_cmd.arg("-C").arg(&source_dir);
clean_cmd.arg("clean").arg("-fd");
let _ = run_command(clean_cmd, logger);
// Hard reset
let mut command = Command::new("git");
command.arg("-C").arg(&source_dir);
command.arg("reset").arg("--hard");
run_command(command, logger)?;
}
}
if let Some(rev) = rev {
@@ -654,9 +586,6 @@ pub fn fetch(recipe: &CookRecipe, check_source: bool, logger: &PtyOut) -> Result
}
manual_git_recursive_submodule(logger, &source_dir, cmds)?;
}
fetch_validate_patch_symlinks(recipe_dir, patches)?;
fetch_apply_patches(recipe_dir, patches, script, &source_dir, logger)?;
}
let (head_rev, _) = get_git_head_rev(&source_dir)?;
@@ -665,7 +594,6 @@ pub fn fetch(recipe: &CookRecipe, check_source: bool, logger: &PtyOut) -> Result
Some(SourceRecipe::Tar {
tar,
blake3,
patches,
script,
}) => {
let source_tar = recipe_dir.join("source.tar");
@@ -708,21 +636,8 @@ pub fn fetch(recipe: &CookRecipe, check_source: bool, logger: &PtyOut) -> Result
}
let mut cached = true;
if source_dir.is_dir() {
if tar_updated || fetch_is_patches_newer(recipe_dir, patches, &source_dir)? {
if is_local_overlay(recipe_dir) && !redbear_allow_protected_fetch() {
log_to_pty!(
logger,
"[WARN] refusing to wipe source for local overlay recipe at {} \
(set REDBEAR_ALLOW_PROTECTED_FETCH=1 to override)",
recipe_dir.display()
);
} else {
log_to_pty!(
logger,
"DEBUG: source tar or patches is newer than the source directory"
);
remove_all(&source_dir)?
}
if tar_updated {
remove_all(&source_dir)?
}
}
if !source_dir.is_dir() {
@@ -730,7 +645,6 @@ pub fn fetch(recipe: &CookRecipe, check_source: bool, logger: &PtyOut) -> Result
let source_dir_tmp = recipe_dir.join("source.tmp");
create_dir_clean(&source_dir_tmp)?;
fetch_extract_tar(source_tar, &source_dir_tmp, logger)?;
fetch_apply_patches(recipe_dir, patches, script, &source_dir_tmp, logger)?;
// Move source.tmp to source atomically
rename(&source_dir_tmp, &source_dir)?;
@@ -1074,559 +988,6 @@ fn read_source_toml(source_toml: &Path) -> Result<pkg::Package> {
Ok(pkg_toml)
}
pub(crate) fn fetch_is_patches_newer(
recipe_dir: &Path,
patches: &Vec<String>,
source_dir: &PathBuf,
) -> Result<bool> {
// don't check source files inside as it can be mixed with user patches
let source_time = modified(&source_dir)?;
for patch_name in patches {
let patch_file = recipe_dir.join(patch_name);
if !patch_file.is_file() {
bail_other_err!("Failed to find patch file {:?}", patch_file.display());
}
let patch_time = modified(&patch_file)?;
if patch_time > source_time {
return Ok(true);
}
}
return Ok(false);
}
pub(crate) fn fetch_apply_patches(
recipe_dir: &Path,
patches: &Vec<String>,
script: &Option<String>,
source_dir_tmp: &PathBuf,
logger: &PtyOut,
) -> Result<()> {
if patches.is_empty() && script.is_none() {
return Ok(());
}
// Read and normalize all patch files.
let mut patch_contents: Vec<(String, Vec<u8>)> = Vec::new();
for patch_name in patches {
let patch_file = recipe_dir.join(patch_name);
if !patch_file.is_file() {
bail_other_err!("Failed to find patch file {:?}", patch_file.display());
}
let raw = fs::read(&patch_file).map_err(|err| {
format!(
"failed to read patch file '{}': {err}",
patch_file.display()
)
})?;
let normalized = normalize_patch(&raw);
patch_contents.push((patch_name.clone(), normalized));
}
// Apply all patches atomically to a staging directory.
// If any patch fails, the staging directory is discarded and the
// original source tree is left untouched.
// Uses cp -al (hard links) for zero-copy staging.
let staging_dir = source_dir_tmp.with_extension("staging");
let _ = fs::remove_dir_all(&staging_dir);
Command::new("cp")
.arg("-al")
.arg(source_dir_tmp)
.arg(&staging_dir)
.status()
.map_err(|e| format!("failed to create staging copy via cp -al: {e}"))?;
// Snapshot pre-existing .orig files in the source tree (some upstreams
// ship .orig files in their tarballs — e.g. glib test data). Only .orig
// files created by the patch command should be flagged as failures.
let preexisting_origs: std::collections::HashSet<String> = {
let out = Command::new("find")
.arg(&staging_dir)
.arg("-name")
.arg("*.orig")
.output();
match out {
Ok(o) => String::from_utf8_lossy(&o.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect(),
Err(_) => std::collections::HashSet::new(),
}
};
let result = (|| -> Result<Vec<String>> {
let mut applied = Vec::new();
for (patch_name, patch_data) in &patch_contents {
let mut command = Command::new("patch");
command.arg("--directory").arg(&staging_dir);
command.arg("--strip=1");
command.arg("--batch");
command.arg("--fuzz=3");
command.arg("--no-backup-if-mismatch");
run_command_stdin(command, patch_data.as_slice(), logger)
.map_err(|e| format!("patch {patch_name} FAILED: {e}"))?;
// .rej files always indicate failure — check unconditionally.
let rej_check = Command::new("find")
.arg(&staging_dir)
.arg("-name")
.arg("*.rej")
.arg("-print")
.arg("-quit")
.output();
if let Ok(out) = rej_check {
if !out.stdout.is_empty() {
let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
bail_other_err!(
"patch {patch_name} left .rej file (hunks failed to apply): {path}"
);
}
}
// .orig files: only flag newly-created ones (not pre-existing).
let orig_check = Command::new("find")
.arg(&staging_dir)
.arg("-name")
.arg("*.orig")
.output();
if let Ok(out) = orig_check {
for line in String::from_utf8_lossy(&out.stdout).lines() {
let trimmed = line.trim().to_string();
if !trimmed.is_empty() && !preexisting_origs.contains(&trimmed) {
bail_other_err!(
"patch {patch_name} left .orig file (hunks failed to apply): {trimmed}"
);
}
}
}
applied.push(patch_name.clone());
}
Ok(applied)
})();
match result {
Ok(applied) => {
let backup_dir = source_dir_tmp.with_extension("backup");
let _ = fs::remove_dir_all(&backup_dir);
fs::rename(source_dir_tmp, &backup_dir)
.map_err(|e| format!("failed to rename source to backup: {e}"))?;
fs::rename(&staging_dir, source_dir_tmp)
.map_err(|e| format!("failed to promote staging to source: {e}"))?;
let _ = fs::remove_dir_all(&backup_dir);
fetch_write_patches_state(recipe_dir, &applied, source_dir_tmp, script, logger)?;
if let Some(script) = script {
let mut command = Command::new("bash");
command.arg("-ex");
command.current_dir(source_dir_tmp);
run_command_stdin(
command,
format!("{SHARED_PRESCRIPT}\n{script}").as_bytes(),
logger,
)?;
}
log_to_pty!(
logger,
"[ATOMIC] {n}/{n} patches applied",
n = applied.len()
);
Ok(())
}
Err(e) => {
let _ = fs::remove_dir_all(&staging_dir);
log_to_pty!(
logger,
"[ATOMIC] patch application rolled back — source tree unchanged"
);
Err(e)
}
}
}
pub fn validate_patches(recipe: &CookRecipe, logger: &PtyOut) -> Result<()> {
let recipe_dir = &recipe.dir;
let source_dir = recipe_dir.join("source");
if !source_dir.is_dir() {
bail_other_err!(
"Source directory does not exist: {}. Fetch first.",
source_dir.display()
);
}
let (patches, script) = match &recipe.recipe.source {
Some(SourceRecipe::Git {
patches, script, ..
})
| Some(SourceRecipe::Tar {
patches, script, ..
}) => (patches.clone(), script.clone()),
_ => {
log_to_pty!(logger, "[INFO] Recipe has no patches to validate");
return Ok(());
}
};
if patches.is_empty() && script.is_none() {
log_to_pty!(logger, "[INFO] No patches to validate");
return Ok(());
}
// Validate patch symlinks
fetch_validate_patch_symlinks(recipe_dir, &patches)?;
// Read and normalize all patch files
let mut patch_contents: Vec<(String, Vec<u8>)> = Vec::new();
for patch_name in &patches {
let patch_file = recipe_dir.join(patch_name);
if !patch_file.is_file() {
bail_other_err!("Failed to find patch file {:?}", patch_file.display());
}
let raw = fs::read(&patch_file).map_err(|err| {
format!(
"failed to read patch file '{}': {err}",
patch_file.display()
)
})?;
let normalized = normalize_patch(&raw);
patch_contents.push((patch_name.clone(), normalized));
}
// Create temp staging directory on same filesystem as source (for hard link support)
let staging_dir = source_dir.with_extension("validate-staging");
let _ = fs::remove_dir_all(&staging_dir);
// cp -al (hard link copy, zero cost) from source to staging
Command::new("cp")
.arg("-al")
.arg(&source_dir)
.arg(&staging_dir)
.status()
.map_err(|e| format!("failed to create staging copy via cp -al: {e}"))?;
// Clean the staging copy to pristine upstream state.
// Only git-sourced recipes have a .git directory — tarball sources
// are already pristine from the cp -al copy.
if staging_dir.join(".git").exists() {
let _ = Command::new("git")
.arg("-C")
.arg(&staging_dir)
.arg("clean")
.arg("-ffdx")
.status();
Command::new("git")
.arg("-C")
.arg(&staging_dir)
.arg("reset")
.arg("--hard")
.status()
.map_err(|e| format!("failed to reset staging to clean state: {e}"))?;
}
let mut passed = 0;
let mut failed = 0;
for (patch_name, patch_data) in &patch_contents {
log_to_pty!(logger, " {} ...", patch_name);
let mut command = Command::new("patch");
command.arg("--directory").arg(&staging_dir);
command.arg("--strip=1");
command.arg("--batch");
command.arg("--fuzz=3");
command.arg("--no-backup-if-mismatch");
match run_command_stdin(command, patch_data.as_slice(), logger) {
Ok(_) => {
// Check for .rej files
let rej_check = Command::new("find")
.arg(&staging_dir)
.arg("-name")
.arg("*.rej")
.arg("-print")
.arg("-quit")
.output();
if let Ok(out) = rej_check {
if !out.stdout.is_empty() {
let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
log_to_pty!(
logger,
" [FAIL] {} → {} has rejected hunks",
patch_name,
path
);
failed += 1;
continue;
}
}
log_to_pty!(logger, " [PASS] {}", patch_name);
passed += 1;
}
Err(e) => {
log_to_pty!(logger, " [FAIL] {} → {}", patch_name, e);
failed += 1;
}
}
}
// Run script if present
if let Some(script) = script {
log_to_pty!(logger, " [script] ...");
let mut command = Command::new("bash");
command.arg("-ex");
command.current_dir(&staging_dir);
match run_command_stdin(
command,
format!("{}\n{}", SHARED_PRESCRIPT, script).as_bytes(),
logger,
) {
Ok(_) => {
log_to_pty!(logger, " [PASS] script");
passed += 1;
}
Err(e) => {
log_to_pty!(logger, " [FAIL] script → {}", e);
failed += 1;
}
}
}
// Clean up staging directory
let _ = fs::remove_dir_all(&staging_dir);
if failed > 0 {
bail_other_err!(
"[SUMMARY] {}/{} patches applied, {} failed",
passed,
passed + failed,
failed
);
}
log_to_pty!(
logger,
"[SUMMARY] All {} patches validated successfully",
passed
);
Ok(())
}
/// Normalizes a patch for compatibility with the `patch` command by stripping
/// git-specific headers (`diff --git`, `index`, `new file mode`, etc.) that
/// `patch` does not recognize.
fn normalize_patch(raw: &[u8]) -> Vec<u8> {
let text = String::from_utf8_lossy(raw);
let mut out = String::with_capacity(text.len());
let mut prev_empty = true;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with("diff --git ")
|| trimmed.starts_with("diff -ruN ")
|| trimmed.starts_with("index ")
|| trimmed.starts_with("new file mode ")
|| trimmed.starts_with("deleted file mode ")
|| trimmed.starts_with("rename from ")
|| trimmed.starts_with("rename to ")
|| trimmed.starts_with("similarity index ")
|| trimmed.starts_with("dissimilarity index ")
{
continue;
}
if !prev_empty || !line.is_empty() {
out.push_str(line);
out.push('\n');
prev_empty = line.is_empty();
}
}
if !out.ends_with('\n') {
out.push('\n');
}
out.into_bytes()
}
/// Computes a BLAKE3 hash over all patch file contents (in order).
fn fetch_compute_patches_hash(recipe_dir: &Path, patches: &[String]) -> Result<String> {
// BLAKE3 is already a project dependency (used for source verification).
let mut hasher = blake3::Hasher::new();
for patch_name in patches {
let patch_file = recipe_dir.join(patch_name);
let content = fs::read(&patch_file).map_err(|err| {
format!(
"failed to read patch for hashing '{}': {err}",
patch_file.display()
)
})?;
hasher.update(&content);
}
Ok(hasher.finalize().to_hex().to_string())
}
/// Writes a .patches-state file into the recipe's *target* directory
/// (NOT the source checkout — git clean would delete it otherwise).
/// Contains: upstream commit, ordered patch list, composite hash, script hash,
/// and state schema version for forward-compatibility.
/// Computes a BLAKE3 hash over all tracked files in the source directory,
/// so that manual source edits (outside the patch system) are detected
/// and trigger re-patching on the next build.
fn fetch_compute_source_hash(source_dir: &Path) -> String {
let output = Command::new("git")
.arg("-C")
.arg(source_dir)
.args(["ls-files", "-z"])
.output();
match output {
Ok(out) if !out.stdout.is_empty() => {
let mut hasher = blake3::Hasher::new();
// Hash file paths in sorted order for stability.
let mut files: Vec<&str> = out
.stdout
.split(|&b| b == 0)
.filter_map(|s| std::str::from_utf8(s).ok())
.collect();
files.sort();
for path in &files {
hasher.update(path.as_bytes());
hasher.update(b"\0");
// Hash file contents for integrity.
if let Ok(content) = fs::read(source_dir.join(path)) {
hasher.update(&content);
}
hasher.update(b"\0");
}
hasher.finalize().to_hex().to_string()
}
_ => "no-git".to_string(),
}
}
fn fetch_write_patches_state(
recipe_dir: &Path,
applied: &[String],
source_dir: &Path,
script: &Option<String>,
logger: &PtyOut,
) -> Result<()> {
let head_rev = get_git_head_rev(&source_dir.to_path_buf())
.map(|(r, _)| r)
.unwrap_or_else(|_| "unknown".to_string());
let hash = fetch_compute_patches_hash(recipe_dir, applied)
.unwrap_or_else(|_| "hash-error".to_string());
let script_hash = script
.as_ref()
.map(|s| blake3::hash(s.as_bytes()).to_hex().to_string())
.unwrap_or_else(|| "none".to_string());
// State goes in target/ so git clean/reset won't delete it.
let state_dir = recipe_dir.join("target");
let _ = fs::create_dir_all(&state_dir);
let state_file = state_dir.join(".patches-state");
let source_hash = fetch_compute_source_hash(source_dir);
let mut content = String::new();
content.push_str("schema: 1\n");
content.push_str(&format!("upstream-rev: {head_rev}\n"));
content.push_str(&format!("patches-hash: {hash}\n"));
content.push_str(&format!("script-hash: {script_hash}\n"));
content.push_str(&format!("source-hash: {source_hash}\n"));
for (i, name) in applied.iter().enumerate() {
content.push_str(&format!("patch[{}]: {name}\n", i + 1));
}
fs::write(&state_file, &content)
.map_err(|err| format!("failed to write .patches-state: {err}"))?;
log_to_pty!(
logger,
"[OK] wrote .patches-state ({}/{} patches)",
applied.len(),
applied.len()
);
Ok(())
}
/// Validates that every patch file path resolves to a real file before we
/// touch the source tree. Fails early with a clear message if any symlink
/// is broken or file is missing.
fn fetch_validate_patch_symlinks(recipe_dir: &Path, patches: &[String]) -> Result<()> {
let mut seen = std::collections::HashSet::new();
for patch_name in patches {
let patch_file = recipe_dir.join(patch_name);
if !patch_file.is_file() {
bail_other_err!(
"patch file not found: {:?} (broken symlink or missing file in {})",
patch_file.display(),
recipe_dir.display()
);
}
// Canonicalize to catch symlink chains
let canonical = patch_file.canonicalize().map_err(|e| {
format!(
"cannot resolve patch path {:?}: {e} (broken symlink?)",
patch_file.display()
)
})?;
if !seen.insert(canonical) {
bail_other_err!(
"duplicate patch after canonicalization: {:?} (listed twice in recipe?)",
patch_name
);
}
}
Ok(())
}
/// Checks whether the source directory's .patches-state matches the
/// recipe's current patch list. Returns true if patches should be
/// (re-)applied.
fn fetch_patches_state_stale(
recipe_dir: &Path,
patches: &[String],
script: &Option<String>,
source_dir: &Path,
) -> bool {
let state_file = recipe_dir.join("target/.patches-state");
let state_content = match fs::read_to_string(&state_file) {
Ok(c) => c,
Err(_) => return true,
};
let expected_hash = match fetch_compute_patches_hash(recipe_dir, patches) {
Ok(h) => h,
Err(_) => return true,
};
let expected_script_hash = script
.as_ref()
.map(|s| blake3::hash(s.as_bytes()).to_hex().to_string())
.unwrap_or_else(|| "none".to_string());
let current_source_hash = fetch_compute_source_hash(source_dir);
let mut found_hash = false;
let mut found_script = false;
let mut found_source = false;
for line in state_content.lines() {
if let Some(stored) = line.strip_prefix("patches-hash: ") {
if stored.trim() != expected_hash {
return true;
}
found_hash = true;
}
if let Some(stored) = line.strip_prefix("script-hash: ") {
if stored.trim() != expected_script_hash {
return true;
}
found_script = true;
}
if let Some(stored) = line.strip_prefix("source-hash: ") {
if stored.trim() != current_source_hash {
return true;
}
found_source = true;
}
}
!found_hash || !found_script || !found_source
}
pub(crate) fn fetch_apply_source_info(
recipe: &CookRecipe,
+8 -11
View File
@@ -42,9 +42,6 @@ pub enum SourceRecipe {
rev: Option<String>,
/// The optional config to clone with treeless clone. Default is true if "rev" added
shallow_clone: Option<bool>,
/// A list of patch files to apply to the source
#[serde(default)]
patches: Vec<String>,
/// Optional script to run to prepare the source
script: Option<String>,
},
@@ -55,12 +52,16 @@ pub enum SourceRecipe {
/// The optional blake3 sum of the tar file. Please specify this to make reproducible
/// builds more reliable
blake3: Option<String>,
/// A list of patch files to apply to the source
#[serde(default)]
patches: Vec<String>,
/// Optional script to run to prepare the source, such as ./autogen.sh
script: Option<String>,
},
/// Direct local source — for Red Bear-owned forks.
/// Source lives in local/sources/<component>/ and is directly editable.
/// No fetching, no patching, no cloning.
Local {
/// Path to the source directory, relative to the recipe directory
path: String,
},
}
/// Specifies how to build a recipe
@@ -485,7 +486,6 @@ impl CookRecipe {
SourceRecipe::Tar {
tar,
blake3: _,
patches: _,
script: _,
} => {
if let Some(ver) = re.extract_ver(&tar) {
@@ -498,7 +498,6 @@ impl CookRecipe {
branch,
rev,
shallow_clone: _,
patches: _,
script: _,
} => {
if let Some(rev) = rev {
@@ -679,9 +678,8 @@ mod tests {
upstream: None,
branch: Some("master".to_string()),
rev: Some("06344744d3d55a5ac9a62a6059cb363d40699bbc".to_string()),
patches: Vec::new(),
script: None,
shallow_clone: None,
script: None,
}),
build: BuildRecipe::new(BuildKind::Cargo {
cargopath: None,
@@ -720,7 +718,6 @@ mod tests {
"8220c0e4082fa26c07b10bfe31f641d2e33ebe1d1bb0b20221b7016bc8b78a3a"
.to_string()
),
patches: Vec::new(),
script: None,
}),
build: BuildRecipe::new(BuildKind::Custom {