workspace-mgr 0.2.2

Fixed-policy repository workspace manager for coding agents
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

use crate::error::{Error, Result};
use crate::process::{CommandOutput, run_unchecked, run_with};

#[derive(Debug, Clone)]
pub struct GitRepo {
    pub root: PathBuf,
}

impl GitRepo {
    pub fn discover(path: &Path) -> Result<Self> {
        let candidate = path.canonicalize().map_err(|source| Error::Io {
            path: path.to_path_buf(),
            source,
        })?;
        let output = run_unchecked(
            "git",
            [
                "-C",
                &candidate.to_string_lossy(),
                "rev-parse",
                "--show-toplevel",
            ],
            &candidate,
        )?;
        if !output.success() {
            return Err(Error::message(format!(
                "not inside a Git repository: {}",
                candidate.display()
            )));
        }
        Ok(Self {
            root: PathBuf::from(output.stdout.trim()),
        })
    }

    pub fn discover_for_manifest(path: &Path) -> Result<Self> {
        let parent = path
            .parent()
            .ok_or_else(|| Error::message("manifest path has no parent"))?;
        if let Ok(repo) = Self::discover(parent) {
            return Ok(repo);
        }
        let worktree_git_dir = parent
            .parent()
            .ok_or_else(|| Error::message("private manifest has no worktree Git directory"))?;
        let pointer = worktree_git_dir.join("gitdir");
        let git_file = fs::read_to_string(&pointer).map_err(|source| crate::error::Error::Io {
            path: pointer.clone(),
            source,
        })?;
        let checkout = PathBuf::from(git_file.trim())
            .parent()
            .ok_or_else(|| Error::message("worktree Git pointer has no checkout parent"))?
            .to_path_buf();
        Self::discover(&checkout)
    }

    pub fn run<I, S>(&self, args: I) -> Result<CommandOutput>
    where
        I: IntoIterator<Item = S>,
        S: AsRef<str>,
    {
        let mut full = vec!["-C".to_owned(), self.root.to_string_lossy().into_owned()];
        full.extend(args.into_iter().map(|arg| arg.as_ref().to_owned()));
        run_with("git", full, &self.root, &BTreeMap::new(), None, true)
    }

    pub fn run_unchecked<I, S>(&self, args: I) -> Result<CommandOutput>
    where
        I: IntoIterator<Item = S>,
        S: AsRef<str>,
    {
        let mut full = vec!["-C".to_owned(), self.root.to_string_lossy().into_owned()];
        full.extend(args.into_iter().map(|arg| arg.as_ref().to_owned()));
        run_with("git", full, &self.root, &BTreeMap::new(), None, false)
    }

    pub fn run_with_index<I, S>(
        &self,
        index: &Path,
        args: I,
        input: Option<&str>,
        check: bool,
    ) -> Result<CommandOutput>
    where
        I: IntoIterator<Item = S>,
        S: AsRef<str>,
    {
        let mut full = vec!["-C".to_owned(), self.root.to_string_lossy().into_owned()];
        full.extend(args.into_iter().map(|arg| arg.as_ref().to_owned()));
        let env = BTreeMap::from([(
            "GIT_INDEX_FILE".to_owned(),
            index.to_string_lossy().into_owned(),
        )]);
        run_with("git", full, &self.root, &env, input, check)
    }

    pub fn visible_paths(&self, scopes: &[String]) -> Result<Vec<String>> {
        let mut args = vec![
            "ls-files".to_owned(),
            "--cached".to_owned(),
            "--others".to_owned(),
            "--exclude-standard".to_owned(),
            "-z".to_owned(),
            "--".to_owned(),
        ];
        if scopes.is_empty() {
            args.push(".".to_owned());
        } else {
            args.extend(scopes.iter().cloned());
        }
        let mut paths = Vec::new();
        for path in self
            .run(args)?
            .stdout
            .split('\0')
            .filter(|path| !path.is_empty())
        {
            match fs::symlink_metadata(self.root.join(path)) {
                Ok(_) => paths.push(path.to_owned()),
                Err(error)
                    if matches!(
                        error.kind(),
                        std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
                    ) => {}
                Err(source) => {
                    return Err(Error::Io {
                        path: self.root.join(path),
                        source,
                    });
                }
            }
        }
        paths.sort();
        paths.dedup();
        Ok(paths)
    }

    pub fn common_dir(&self) -> Result<PathBuf> {
        let raw = self.run(["rev-parse", "--git-common-dir"])?.stdout;
        let path = PathBuf::from(raw.trim());
        if path.is_absolute() {
            Ok(path)
        } else {
            Ok(self.root.join(path))
        }
    }

    pub fn git_dir(&self) -> Result<PathBuf> {
        let raw = self.run(["rev-parse", "--git-dir"])?.stdout;
        let path = PathBuf::from(raw.trim());
        if path.is_absolute() {
            Ok(path)
        } else {
            Ok(self.root.join(path))
        }
    }

    pub fn branch_worktrees(&self, branch: &str) -> Result<Vec<PathBuf>> {
        let target = format!("branch refs/heads/{branch}");
        let mut worktree = None;
        let mut matches = Vec::new();
        for line in self
            .run(["worktree", "list", "--porcelain"])?
            .stdout
            .lines()
        {
            if let Some(path) = line.strip_prefix("worktree ") {
                worktree = Some(PathBuf::from(path));
            } else if line == target {
                if let Some(path) = worktree.take() {
                    matches.push(path);
                }
            }
        }
        Ok(matches)
    }

    pub fn current_branch(&self) -> Result<Option<String>> {
        let output = self.run_unchecked(["symbolic-ref", "--quiet", "--short", "HEAD"])?;
        match output.code {
            0 => Ok(Some(output.stdout.trim().to_owned())),
            1 => Ok(None),
            _ => Err(Error::message(command_detail(
                &output.stderr,
                "failed to read current branch",
            ))),
        }
    }

    pub fn optional_oid(&self, reference: &str) -> Result<Option<String>> {
        let output = self.run_unchecked(["rev-parse", "--verify", "--quiet", reference])?;
        match output.code {
            0 => Ok(Some(output.stdout.trim().to_owned())),
            1 => Ok(None),
            _ => Err(Error::message(command_detail(
                &output.stderr,
                &format!("failed to resolve {reference}"),
            ))),
        }
    }

    pub fn fetch_branch(&self, remote: &str, branch: &str) -> Result<String> {
        let remote_ref = format!("refs/remotes/{remote}/{branch}");
        let refspec = format!("+refs/heads/{branch}:{remote_ref}");
        self.run([
            "fetch",
            "--quiet",
            "--no-tags",
            "--no-write-fetch-head",
            remote,
            &refspec,
        ])?;
        self.optional_oid(&remote_ref)?
            .ok_or_else(|| Error::message(format!("fetch did not create {remote_ref}")))
    }

    pub fn remote_branch_oid(&self, remote: &str, branch: &str) -> Result<Option<String>> {
        let reference = format!("refs/heads/{branch}");
        let output = self.run(["ls-remote", "--heads", remote, &reference])?;
        let lines: Vec<&str> = output
            .stdout
            .lines()
            .filter(|line| !line.trim().is_empty())
            .collect();
        if lines.is_empty() {
            return Ok(None);
        }
        if lines.len() != 1 {
            return Err(Error::message(format!(
                "remote returned multiple matches for {reference}"
            )));
        }
        let mut parts = lines[0].split_whitespace();
        let oid = parts.next().unwrap_or_default();
        let actual = parts.next().unwrap_or_default();
        if actual != reference {
            return Err(Error::message(format!("unexpected remote ref {actual:?}")));
        }
        Ok(Some(oid.to_owned()))
    }

    pub fn ensure_branch_not_checked_out(&self, branch: &str) -> Result<()> {
        if !self.branch_worktrees(branch)?.is_empty() {
            return Err(Error::message(format!(
                "target branch {branch:?} is checked out in a worktree"
            )));
        }
        Ok(())
    }

    pub fn validate_branch(&self, branch: &str) -> Result<()> {
        self.run(["check-ref-format", &format!("refs/heads/{branch}")])?;
        Ok(())
    }

    pub fn validate_remote_name(&self, remote: &str) -> Result<()> {
        if remote.starts_with('-') || remote.chars().any(char::is_whitespace) {
            return Err(Error::message(format!(
                "unsafe Git remote name {remote:?}; configure a named remote"
            )));
        }
        let probe = format!("refs/remotes/{remote}/workspace-mgr-probe");
        let checked = self.run_unchecked(["check-ref-format", &probe])?;
        if !checked.success() {
            return Err(Error::message(format!(
                "invalid Git remote name {remote:?}; configure a named remote"
            )));
        }
        Ok(())
    }
}

fn command_detail(stderr: &str, fallback: &str) -> String {
    let detail = stderr.trim();
    if detail.is_empty() {
        fallback.to_owned()
    } else {
        detail.to_owned()
    }
}