workon 0.27.0

Development workspace launcher with Zellij, Claude CLI, and branchdiff
Documentation
//! What a new workspace must not inherit from the project's gitignored files.
//!
//! `workon create` clones the project's ignored files into the fresh worktree
//! so a workspace starts warm. Two kinds of that content (workon#6) go stale
//! against the commit the workspace checked out: dependency trees installed
//! from the project's *current* lockfile — `node_modules` — and the Rails
//! asset build output — `public/packs-test` and its siblings — which the next
//! build regenerates anyway. The trees are still cloned (that is usually the
//! point), but a tree whose lockfile hash no longer matches the workspace's
//! own gets one clear warning line naming the reinstall; the output is never
//! cloned.

use std::path::Path;

/// Generated build output: stale the moment the workspace checks out a
/// different commit, and regenerated by the next build, so never cloned into a
/// new workspace. Matched on whole path components (see [`inside`]).
const STALE_OUTPUT: &[&str] = &["public/assets", "public/packs", "public/packs-test"];

/// The lockfiles whose dependency trees workon clones: the lockfile, the
/// directory the installed tree lives in (drift is worth a warning only when
/// that directory exists in the project, i.e. the tree really was cloned),
/// and the command that reinstalls from the workspace's own lockfile.
const LOCKFILES: &[(&str, &str, &str)] = &[
    ("package-lock.json", "node_modules", "npm install"),
    ("npm-shrinkwrap.json", "node_modules", "npm install"),
    ("yarn.lock", "node_modules", "yarn install"),
    ("pnpm-lock.yaml", "node_modules", "pnpm install"),
];

/// Whether `path` is `dir` itself or something inside it, on whole path
/// components: `public/packs-test` is not inside `public/packs`.
fn inside(dir: &str, path: &str) -> bool {
    path.strip_prefix(dir).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
}

/// Whether a gitignored path is generated build output ([`STALE_OUTPUT`]):
/// not something to clone into a workspace checked out at another commit.
fn is_stale_output(path: &str) -> bool {
    STALE_OUTPUT.iter().any(|dir| inside(dir, path))
}

/// The cloneable subset of the gitignored file list: generated build output
/// dropped, everything else kept, order preserved.
pub(crate) fn without_stale_output(files: &[String]) -> Vec<String> {
    files.iter().filter(|f| !is_stale_output(f)).cloned().collect()
}

/// What the drift check says about one lockfile: the cloned dependency tree
/// can no longer match what the workspace's build expects.
enum Drift {
    /// The lockfile the tree was installed from differs from the workspace's.
    Changed,
    /// The project has a lockfile behind its tree; the workspace's commit has none.
    Missing,
}

/// Drift between the lockfile the project's installed dependencies came from
/// (`installed`) and the workspace's (`workspace`), compared by hash. `None`
/// when there is nothing to warn about: no lockfile behind the tree to
/// compare, or the two agree.
fn lock_drift(installed: Option<&str>, workspace: Option<&str>) -> Option<Drift> {
    match (installed, workspace) {
        (None, _) => None,
        (Some(_), None) => Some(Drift::Missing),
        (Some(a), Some(b)) if a != b => Some(Drift::Changed),
        (Some(_), Some(_)) => None,
    }
}

/// The one warning line for one lockfile's drift (workon#6), or nothing.
fn drift_line(
    lock: &str,
    deps: &str,
    install: &str,
    installed: Option<&str>,
    workspace: Option<&str>,
) -> Option<String> {
    match lock_drift(installed, workspace) {
        Some(Drift::Changed) => Some(format!(
            "Warning: {deps} was cloned from a {lock} that differs from this workspace's; run '{install}' in the workspace."
        )),
        Some(Drift::Missing) => Some(format!(
            "Warning: {deps} was cloned from the project's {lock}, which this workspace does not have; run '{install}' in the workspace."
        )),
        None => None,
    }
}

/// The sha256 of the lockfile at `path`, if there is one to hash.
fn lockfile_hash(path: &Path) -> Option<String> {
    std::fs::read(path).ok().map(|bytes| crate::trust::sha256_hex(&bytes))
}

/// The drift warnings for one project/workspace pair: one line per lockfile
/// whose project-side hash (what the cloned tree was installed from) no
/// longer matches the workspace's own commit. Silent when no tree was
/// cloned, when the project has no lockfile behind its tree, or when the
/// two agree.
fn drift_lines(project_dir: &Path, ws_dir: &Path) -> Vec<String> {
    let mut lines = Vec::new();
    for &(lock, deps, install) in LOCKFILES {
        // Only a tree that exists in the project was cloned into the workspace.
        if !project_dir.join(deps).is_dir() {
            continue;
        }
        let installed = lockfile_hash(&project_dir.join(lock));
        let workspace = lockfile_hash(&ws_dir.join(lock));
        if let Some(line) = drift_line(lock, deps, install, installed.as_deref(), workspace.as_deref()) {
            lines.push(line);
        }
    }
    lines
}

/// `workon create`'s drift check, run after the gitignored files are cloned
/// into the fresh workspace: one warning line per lockfile the cloned
/// dependency trees were installed from but the workspace's own commit does
/// not share.
pub(crate) fn warn_lockfile_drift(project_dir: &Path, ws_dir: &Path) {
    for line in drift_lines(project_dir, ws_dir) {
        eprintln!("{line}");
    }
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use super::*;

    /// A directory tree named `name` under `tmp` holding `files`.
    fn tree(tmp: &Path, name: &str, files: &[(&str, &str)]) -> PathBuf {
        let root = tmp.join(name);
        for (rel, body) in files {
            let path = root.join(rel);
            std::fs::create_dir_all(path.parent().expect("a parent")).expect("the parent dirs");
            std::fs::write(path, body).expect("the file");
        }
        root
    }

    /// The component match the skip is built on: a path is inside a dir, or is
    /// the dir itself, but a name that merely starts the same is another dir.
    #[test]
    fn inside_matches_whole_components_only() {
        for (dir, path, expected) in [
            ("public/packs-test", "public/packs-test", true),
            ("public/packs-test", "public/packs-test/js/manifest.js", true),
            ("public/packs", "public/packs-old/x", false),
            ("public/assets", "public/assets-old/x", false),
            ("public/packs", "public/packs-test/x", false),
        ] {
            assert_eq!(inside(dir, path), expected, "inside({dir:?}, {path:?})");
        }
    }

    /// The regression from workon#6: `public/packs-test` and its siblings are
    /// dropped from the clone; lookalike directory names and content a
    /// workspace wants to keep are not.
    #[test]
    fn without_stale_output_drops_generated_output_and_keeps_lookalikes() {
        let files: Vec<String> = [
            "node_modules/leftpad/index.js",
            "public/packs-test/js/manifest.js",
            "public/packs/js/manifest.js",
            "public/assets/.sprockets-manifest.json",
            "tmp/cache/bootsnap-test.bin",
            "build/out.o",
            "public/packs-old/keep.js",
            "public/uploads/avatar.png",
            ".env",
        ]
        .iter()
        .map(|s| s.to_string())
        .collect();
        let expected: Vec<String> = [
            "node_modules/leftpad/index.js",
            "tmp/cache/bootsnap-test.bin",
            "build/out.o",
            "public/packs-old/keep.js",
            "public/uploads/avatar.png",
            ".env",
        ]
        .iter()
        .map(|s| s.to_string())
        .collect();
        assert_eq!(without_stale_output(&files), expected);
    }

    const LOCK_A: &str = "{\"lockfileVersion\":3,\"packages\":\"a\"}";
    const LOCK_B: &str = "{\"lockfileVersion\":3,\"packages\":\"b\"}";

    /// A project whose node_modules was installed from a different lockfile
    /// than the workspace's commit gets the one warning line, naming the
    /// tree, the lockfile and the reinstall (workon#6).
    #[test]
    fn drift_lines_warn_when_the_cloned_tree_came_from_a_different_lockfile() {
        let tmp = tempfile::tempdir().unwrap();
        let project =
            tree(tmp.path(), "project", &[("package-lock.json", LOCK_A), ("node_modules/leftpad/index.js", "x\n")]);
        let workspace = tree(tmp.path(), "workspace", &[("package-lock.json", LOCK_B)]);

        assert_eq!(
            drift_lines(&project, &workspace),
            vec![
                "Warning: node_modules was cloned from a package-lock.json that differs from this workspace's; run 'npm install' in the workspace."
                    .to_string()
            ]
        );
    }

    /// Matching lockfiles — the tree was installed from the very lockfile the
    /// workspace checks out — mean nothing to warn about.
    #[test]
    fn drift_lines_are_silent_when_the_lockfiles_match() {
        let tmp = tempfile::tempdir().unwrap();
        let project =
            tree(tmp.path(), "project", &[("package-lock.json", LOCK_A), ("node_modules/leftpad/index.js", "x\n")]);
        let workspace = tree(tmp.path(), "workspace", &[("package-lock.json", LOCK_A)]);

        assert!(drift_lines(&project, &workspace).is_empty());
    }

    /// A tree installed from a lockfile the workspace's commit does not have
    /// is drift too: the warning names it (workon#6).
    #[test]
    fn drift_lines_warn_when_the_workspace_has_no_lockfile() {
        let tmp = tempfile::tempdir().unwrap();
        let project =
            tree(tmp.path(), "project", &[("package-lock.json", LOCK_A), ("node_modules/leftpad/index.js", "x\n")]);
        let workspace = tree(tmp.path(), "workspace", &[("node_modules/leftpad/index.js", "x\n")]);

        assert_eq!(
            drift_lines(&project, &workspace),
            vec![
                "Warning: node_modules was cloned from the project's package-lock.json, which this workspace does not have; run 'npm install' in the workspace."
                    .to_string()
            ]
        );
    }

    /// Without a tree to have cloned, or without a lockfile behind the
    /// project's tree, there is nothing to compare and nothing to say.
    #[test]
    fn drift_lines_stay_silent_without_a_tree_or_a_project_lockfile() {
        let tmp = tempfile::tempdir().unwrap();
        // No node_modules in the project: nothing was cloned.
        let no_tree = tree(tmp.path(), "no-tree", &[("package-lock.json", LOCK_A)]);
        let workspace_a = tree(tmp.path(), "ws-a", &[("package-lock.json", LOCK_B)]);
        assert!(drift_lines(&no_tree, &workspace_a).is_empty());

        // No lockfile behind the project's tree: nothing to compare against.
        let no_lock = tree(tmp.path(), "no-lock", &[("node_modules/leftpad/index.js", "x\n")]);
        let workspace_b = tree(tmp.path(), "ws-b", &[("package-lock.json", LOCK_B)]);
        assert!(drift_lines(&no_lock, &workspace_b).is_empty());
    }

    /// The other JavaScript package managers get their own line, with their own
    /// reinstall command (the yarn case is the one workon#6 reported).
    #[test]
    fn drift_lines_warn_per_package_manager() {
        let tmp = tempfile::tempdir().unwrap();

        for (lock, install) in [("yarn.lock", "yarn install"), ("pnpm-lock.yaml", "pnpm install")] {
            let project = tree(
                tmp.path(),
                &format!("project-{lock}"),
                &[(lock, "old"), ("node_modules/leftpad/index.js", "x\n")],
            );
            let workspace = tree(tmp.path(), &format!("workspace-{lock}"), &[(lock, "new")]);
            assert_eq!(
                drift_lines(&project, &workspace),
                vec![format!(
                    "Warning: node_modules was cloned from a {lock} that differs from this workspace's; run '{install}' in the workspace."
                )]
            );
        }
    }

    /// Stale-output dirs, lookalikes that must be kept, and dirs whose
    /// contents a workspace does want.
    const POOL: &[&str] = &[
        "build",
        "dist",
        "tmp",
        "public/assets",
        "public/packs",
        "public/packs-test",
        "public/packs-old",
        "public/assets-old",
        "public",
        "public/uploads",
        "src",
        "app",
        "node_modules",
        "vendor",
        "deps",
        ".venv",
        "target",
    ];

    proptest::proptest! {
        /// A path is dropped from the clone exactly when it sits inside one
        /// of the stale-output dirs, on whole components — a lookalike
        /// (`public/packs-old`) and anything else is kept. (Regression, workon#6:
        /// `public/packs-test` used to be cloned into new workspaces, stale
        /// against the workspace's own commit.)
        #[test]
        fn a_path_is_dropped_exactly_when_it_sits_inside_a_stale_output_dir(
            i in 0..POOL.len(),
            deeper in proptest::collection::vec("[a-z0-9-]{1,10}", 0..3),
        ) {
            let prefix = POOL[i];
            let mut path = prefix.to_string();
            for part in &deeper {
                path.push('/');
                path.push_str(part);
            }
            proptest::prop_assert_eq!(is_stale_output(&path), STALE_OUTPUT.contains(&prefix), "path: {}", path);
            let all = vec![path.clone(), "node_modules/leftpad/index.js".to_string()];
            let expected: Vec<String> = all.iter().filter(|f| !is_stale_output(f)).cloned().collect();
            proptest::prop_assert_eq!(without_stale_output(&all), expected);
        }

        /// The drift check fires exactly when the cloned tree cannot match the
        /// workspace: the lockfile hashes differ, or the workspace has no
        /// lockfile at all. (Regression, workon#6: node_modules installed from
        /// the project's package-lock.json used to be cloned into a workspace
        /// whose own package-lock.json differed, with no warning.)
        #[test]
        fn drift_fires_exactly_when_the_lockfiles_differ_or_the_workspace_lacks_one(
            hash in "[a-f0-9]{64}",
            other in "[a-f0-9]{64}",
            same in proptest::bool::ANY,
            locked in proptest::bool::ANY,
            workspace_locked in proptest::bool::ANY,
        ) {
            let installed = locked.then_some(hash.as_str());
            let workspace = workspace_locked.then_some(if same { hash.as_str() } else { other.as_str() });
            let drift = lock_drift(installed, workspace);
            match (installed, workspace) {
                (None, _) => proptest::prop_assert!(drift.is_none()),
                (Some(_), None) => proptest::prop_assert!(matches!(drift, Some(Drift::Missing))),
                (Some(a), Some(b)) if a == b => proptest::prop_assert!(drift.is_none()),
                (Some(_), Some(_)) => proptest::prop_assert!(matches!(drift, Some(Drift::Changed))),
            }
        }
    }
}