908 lines · 33.5 KB · Rust
RawDownload
// Copyright 2024 The Jujutsu Authors//// Licensed under the Apache License, Version 2.0 (the "License");// you may not use this file except in compliance with the License.// You may obtain a copy of the License at//// https://www.apache.org/licenses/LICENSE-2.0//// Unless required by applicable law or agreed to in writing, software// distributed under the License is distributed on an "AS IS" BASIS,// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.// See the License for the specific language governing permissions and// limitations under the License. //! This file contains the internal implementation of `run`. use std::cmp::min;use std::collections::HashMap;use std::collections::HashSet;use std::fmt;use std::fs;use std::io;use std::io::Write as _;use std::num::NonZeroUsize;use std::path::Path;use std::path::PathBuf;use std::process::ExitStatus;use std::process::Output;use std::process::Stdio;use std::sync::Arc;use std::time::Duration; use futures::StreamExt as _;use futures::TryStreamExt as _;use itertools::Itertools as _;use jj_lib::backend::BackendError;use jj_lib::backend::CommitId;use jj_lib::commit::Commit;use jj_lib::commit::CommitIteratorExt as _;use jj_lib::conflicts::ConflictMarkerStyle;use jj_lib::fsmonitor::FsmonitorSettings;use jj_lib::gitignore::GitIgnoreFile;use jj_lib::local_working_copy::EolConversionMode;use jj_lib::local_working_copy::ExecChangeSetting;use jj_lib::local_working_copy::TreeState;use jj_lib::local_working_copy::TreeStateError;use jj_lib::local_working_copy::TreeStateSettings;use jj_lib::lock::FileLock;use jj_lib::lock::FileLockError;use jj_lib::matchers::EverythingMatcher;use jj_lib::matchers::Matcher;use jj_lib::matchers::NothingMatcher;use jj_lib::merge::Merge;use jj_lib::merged_tree::MergedTree;use jj_lib::object_id::ObjectId as _;use jj_lib::repo::Repo as _;use jj_lib::working_copy::SnapshotOptions;use tokio::runtime::Builder;use tokio::sync::Semaphore;use tokio::sync::mpsc;use tokio::sync::mpsc::Sender;use tokio::task::JoinError;use tokio::task::JoinSet;use tokio::time::sleep; use crate::cli_util::CommandHelper;use crate::cli_util::RevisionArg;use crate::cli_util::WorkspaceCommandHelper;use crate::cli_util::WorkspaceCommandTransaction;use crate::command_error::CommandError;use crate::command_error::CommandErrorKind;use crate::ui::Ui; #[derive(Debug, thiserror::Error)]enum RunError {    #[error("failed to checkout the commit {}", .0)]    FailedCheckout(CommitId),    #[error("the command '{}' failed with {}", .0, .1)]    CommandFailure(String, ExitStatus),    #[error(transparent)]    IoError(#[from] io::Error),    #[error("failed to create path {}: {}", .0.to_string_lossy(), .1)]    PathCreationFailure(PathBuf, io::Error),    #[error("failed to delete path {}: {}", .0.to_string_lossy(), .1)]    PathDeletionFailure(PathBuf, io::Error),    #[error("failed to load a commit's tree")]    TreeState(#[from] TreeStateError),    #[error(transparent)]    Backend(#[from] BackendError),    #[error(transparent)]    JobFailure(#[from] JoinError),    #[error(transparent)]    FileLock(#[from] FileLockError),    #[error("invalid value for `run.jobs`: {0} (must be a positive integer)")]    InvalidJobCount(i64),} impl From<RunError> for CommandError {    fn from(value: RunError) -> Self {        Self::new(CommandErrorKind::User, Box::new(value))    }} fn default_tree_state_settings() -> TreeStateSettings {    TreeStateSettings {        conflict_marker_style: ConflictMarkerStyle::Snapshot,        eol_conversion_mode: EolConversionMode::None,        exec_change_setting: ExecChangeSetting::Auto,        fsmonitor_settings: FsmonitorSettings::None,    }} /// A workspace that's ready for a single job to run against.////// Dropping the workspace releases its interprocess lock, freeing the slot for/// the next worker.struct RunWorkspace {    working_copy_dir: PathBuf,    tree_state: TreeState,    /// Holds the slot's interprocess lock for the duration of the job.    _lock: FileLock,} impl RunWorkspace {    /// Persist the post-snapshot tree state to disk so the next slot    /// acquisition can diff against it and only touch changed files.    fn persist(&mut self) -> Result<(), RunError> {        self.tree_state.save()?;        Ok(())    }} /// A command, its arguments, and the workspace-relative directory it should/// run in.struct CommandSpec {    program: String,    args: Vec<String>,    /// Working directory for the subprocess, relative to the workspace root.    /// Empty means run from the workspace root.    subdir: Option<PathBuf>,} impl fmt::Display for CommandSpec {    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {        f.write_str(&self.program)?;        for arg in &self.args {            f.write_str(" ")?;            f.write_str(arg)?;        }        Ok(())    }} /// Manages a fixed-size pool of workspaces under `.jj/run/default/`.////// Each workspace lives at `.jj/run/default/N/` with subdirs `working_copy/`/// and `state/`. Its lockfile is the sibling `.jj/run/default/N.lock`./// Workspaces persist between `jj run` invocations so build artifacts can be/// reused. Acquisition picks the first free workspace, so multiple concurrent/// `jj run` processes cooperatively share the pool.struct WorkspacePool {    base_path: PathBuf,    size: NonZeroUsize,    /// Determines which untracked files left in a slot get pulled into the    /// rewritten commit. Loaded once from `snapshot.auto-track`; essentially    /// the user's `.gitignore` story for what counts as a build artifact.    auto_tracking_matcher: Box<dyn Matcher>,    /// When true, wipe each slot's working copy on acquisition so every commit    /// starts from a freshly checked-out tree (no artifact reuse).    clean: bool,} impl WorkspacePool {    fn new(        repo_path: &Path,        size: NonZeroUsize,        auto_tracking_matcher: Box<dyn Matcher>,        clean: bool,    ) -> Result<Self, RunError> {        // The parent() call is needed to not write under `.jj/repo/`.        let base_path = repo_path.parent().unwrap().join("run").join("default");        fs::create_dir_all(&base_path)?;        Ok(Self {            base_path,            size,            auto_tracking_matcher,            clean,        })    }     async fn acquire(        &self,        commit: &Commit,        base_ignores: Arc<GitIgnoreFile>,    ) -> Result<RunWorkspace, RunError> {        // Find a free slot. The first iteration may fail if another worker        // (this process or another) holds every slot; sleep and retry.        let mut cur_sleep = Duration::from_millis(10);        let max_sleep = Duration::from_millis(250);        let (slot_index, lock) = loop {            if let Some(found) = self.try_acquire_any_slot()? {                break found;            }            sleep(cur_sleep).await;            cur_sleep = min(cur_sleep.saturating_mul(2), max_sleep);        };         let slot_path = self.slot_path(slot_index);        let working_copy_dir = slot_path.join("working_copy");        let state_dir = slot_path.join("state");        let tree_state_path = state_dir.join("tree_state");         let is_reused_workspace = tree_state_path.exists();        let settings = default_tree_state_settings();        let mut tree_state = if !self.clean && is_reused_workspace {            // Load the persisted tree state so `check_out` below can diff            // against it, only touching files that changed and removing files            // no longer present in the new tree.            //            // Delete `tree_state` from disk before checkout to act as a dirty            // marker. If we crash between here and the save at the end of the            // job, the next acquisition will see the file absent and wipe the            // slot rather than trusting inconsistent state.            let ts = TreeState::load(                commit.store().clone(),                working_copy_dir.clone(),                state_dir.clone(),                &settings,            )?;            fs::remove_file(&tree_state_path)?;            ts        } else {            // This is the first use of the workspace, the previous job crashed,            // or --clean was passed. Wipe any leftover working copy so we start            // from a clean slate, then use an in-memory empty tree state.            // `tree_state` stays absent on disk until a successful job writes            // it via `persist()`.            fs::remove_file(&tree_state_path).or_else(|e| match e {                e if e.kind() == io::ErrorKind::NotFound => Ok(()),                e => Err(RunError::PathDeletionFailure(tree_state_path.clone(), e)),            })?;            fs::remove_dir_all(&working_copy_dir).or_else(|e| match e {                e if e.kind() == io::ErrorKind::NotFound => Ok(()),                e => Err(RunError::PathDeletionFailure(working_copy_dir.clone(), e)),            })?;            fs::remove_dir_all(&state_dir).or_else(|e| match e {                e if e.kind() == io::ErrorKind::NotFound => Ok(()),                e => Err(RunError::PathDeletionFailure(state_dir.clone(), e)),            })?;            fs::create_dir_all(&working_copy_dir)                .map_err(|e| RunError::PathCreationFailure(working_copy_dir.clone(), e))?;            fs::create_dir_all(&state_dir)                .map_err(|e| RunError::PathCreationFailure(state_dir.clone(), e))?;            TreeState::init_without_saving(                commit.store().clone(),                working_copy_dir.clone(),                state_dir,                &settings,            )        };         tree_state            .check_out(&commit.tree())            .map_err(|_| RunError::FailedCheckout(commit.id().clone()))?;         // If we checked out a revision with a completely empty tree,        // TreeState::check_out() deletes the working_copy directory because it        // recursively deletes empty directories until it reaches an error. In        // normal workspaces, the .jj directory prevents the root directory from        // being deleted, but our slots don't have that. It might be better to        // make check_out explicitly avoid deleting the root directory, but that        // needs to be a carefully considered change.        fs::create_dir_all(&working_copy_dir)            .map_err(|e| RunError::PathCreationFailure(working_copy_dir.clone(), e))?;         // Remove files from a previous run that were ignored in the previous        // revision but are not ignored in the new commit:        // - Check out the new revsision        // - Snapshot to discover any new files in the working copy        // - Delete any new files        // - Check out again to reset tree state        if is_reused_workspace {            let options = self.snapshot_options(base_ignores);            tree_state.snapshot(&options).await.unwrap();            let post_snapshot_tree = tree_state.current_tree().clone();            let original_tree = commit.tree();            let mut diff = original_tree.diff_stream(&post_snapshot_tree, &EverythingMatcher);            let mut added_paths = Vec::new();            while let Some(entry) = diff.next().await {                let values = entry.values?;                if values.before.is_absent() && values.after.is_present() {                    added_paths.push(entry.path);                }            }            drop(diff);            for path in &added_paths {                let abs = path.to_fs_path_unchecked(&working_copy_dir);                if let Err(err) = fs::remove_file(&abs)                    && err.kind() != io::ErrorKind::NotFound                {                    return Err(err.into());                }            }            if !added_paths.is_empty() {                tree_state                    .check_out(&original_tree)                    .map_err(|_| RunError::FailedCheckout(commit.id().clone()))?;            }        }         Ok(RunWorkspace {            working_copy_dir,            tree_state,            _lock: lock,        })    }     fn snapshot_options(&self, base_ignores: Arc<GitIgnoreFile>) -> SnapshotOptions<'_> {        SnapshotOptions {            base_ignores,            start_tracking_matcher: self.auto_tracking_matcher.as_ref(),            progress: None,            // TODO: read from current wc/settings            max_new_file_size: 64_000_u64, // 64 MB for now            force_tracking_matcher: &NothingMatcher,        }    }     fn slot_path(&self, index: usize) -> PathBuf {        self.base_path.join(index.to_string())    }     fn slot_lock_path(&self, index: usize) -> PathBuf {        self.base_path.join(format!("{index}.lock"))    }     /// Try to acquire any slot's lock without blocking. Returns the slot    /// index and the held lock if one was available, `Ok(None)` if every    /// slot was contended.    fn try_acquire_any_slot(&self) -> Result<Option<(usize, FileLock)>, RunError> {        for slot in 1..=self.size.get() {            let slot_path = self.slot_path(slot);            fs::create_dir_all(&slot_path)                .map_err(|e| RunError::PathCreationFailure(slot_path.clone(), e))?;            if let Some(lock) = FileLock::try_lock(self.slot_lock_path(slot))? {                tracing::debug!(slot, "acquired pool slot");                return Ok(Some((slot, lock)));            }        }        Ok(None)    }} /// The result of a single command invocation.struct RunJob {    /// The old `CommitId` of the commit.    old_id: CommitId,    /// The original tree of the commit before the command ran.    old_tree: MergedTree,    /// The new tree generated from the commit. `None` when the command wasn't    /// run (i.e. the commit was skipped). May be a conflicted tree when the    /// command left conflicts in the working copy.    new_tree: Option<MergedTree>,    /// Was the tree even modified.    dirty: bool,    /// Bytes the subprocess wrote to its stdout, captured in full.    stdout: Vec<u8>,    /// Bytes the subprocess wrote to its stderr, captured in full.    stderr: Vec<u8>,    /// True if the command wasn't run because the per-commit working directory    /// (the subdirectory `jj run` was invoked from) didn't exist in this    /// commit's tree.    skipped: bool,    /// Exit status of the command, if it ran.    status: Option<ExitStatus>,} // TODO: make this more revset/commit stream friendly.#[allow(clippy::too_many_arguments)]async fn run_inner(    tx: &WorkspaceCommandTransaction<'_>,    sender: Sender<RunJob>,    handle: &tokio::runtime::Handle,    spec: Arc<CommandSpec>,    pool: Arc<WorkspacePool>,    commits: &[Commit],    jobs: usize,    passthrough: bool,) -> Result<(), RunError> {    let base_ignores = tx.base_workspace_helper().base_ignores().unwrap().clone();    let semaphore = Arc::new(Semaphore::new(jobs));    let mut command_futures: JoinSet<Result<RunJob, RunError>> = JoinSet::new();    for commit in commits {        // Acquire the permit before spawning so tasks start in commit order.        let permit = semaphore            .clone()            .acquire_owned()            .await            .expect("semaphore not closed");        let base_ignores = base_ignores.clone();        let pool = pool.clone();        let commit = commit.clone();        let spec = spec.clone();        command_futures.spawn_on(            async move {                let _permit = permit;                // TODO: handle/propagate error here                rewrite_commit(base_ignores, pool, commit, spec, passthrough).await            },            handle,        );    }     while let Some(res) = command_futures.join_next().await {        let done = match res {            Ok(rj) => rj?,            Err(err) => return Err(RunError::JobFailure(err)),        };        let should_quit = sender.send(done).await.is_err();        if should_quit {            tracing::debug!(                ?should_quit,                "receiver is no longer available, exiting loop"            );            break;        }    }    Ok(())} /// Run `spec` against `commit`. The caller is responsible for committing any/// returned new tree to the repo.async fn rewrite_commit(    base_ignores: Arc<GitIgnoreFile>,    pool: Arc<WorkspacePool>,    commit: Commit,    spec: Arc<CommandSpec>,    passthrough: bool,) -> Result<RunJob, RunError> {    let mut workspace = pool.acquire(&commit, base_ignores.clone()).await?;    let working_copy_dir = workspace.working_copy_dir.clone();    let old_id = commit.id().clone();    let old_tree = commit.tree();     // Resolve where the command should run. If the subdir doesn't exist in this    // commit's checked-out tree, skip the commit entirely.    let exec_dir = if let Some(subdir) = &spec.subdir {        let exec_dir = working_copy_dir.join(subdir);        if !exec_dir.is_dir() {            tracing::debug!(                ?exec_dir,                commit = old_id.hex(),                "subdirectory does not exist in commit; skipping"            );            // Persist the post-checkout state so the next pool acquisition            // can diff from this tree even though no command ran.            workspace.persist()?;            return Ok(RunJob {                old_id,                old_tree,                new_tree: None,                dirty: false,                stdout: Vec::new(),                stderr: Vec::new(),                skipped: true,                status: None,            });        }        exec_dir    } else {        working_copy_dir.clone()    };     // TODO: Later this should take some trait which allows `run` to integrate with    // something like Bazels RE protocol.    // e.g    // ```    // let mut executor /* Arc<dyn CommandExecutor> */ = store.get_executor();    // let command = executor.spawn(...)?; // RE or separate processes depending on impl.    // ...    // ```    tracing::debug!("trying to run command '{}' on commit {}", spec, commit.id());     let mut command = tokio::process::Command::new(&spec.program);    command        .args(&spec.args)        .current_dir(&exec_dir)        .env("JJ_WORKSPACE_ROOT", &working_copy_dir)        .env("JJ_CHANGE_ID", commit.change_id().reverse_hex())        .env("JJ_COMMIT_ID", commit.id().hex())        .stdin(Stdio::null())        .kill_on_drop(true);    let output = if passthrough {        // Connect stdout/stderr directly to the terminal so TTY-aware        // programs work as expected. Capture is not possible; we wait        // for the process to exit and return empty stdout/stderr.        command.stdout(Stdio::inherit()).stderr(Stdio::inherit());        let status = command.status().await?;        Output {            status,

Lines 1–500 of 908.