tincan-cli 0.4.0

Preserve development plans, decisions, learnings, and progress in workspace-local Markdown
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

pub fn repository_root(path: &Path) -> Result<Option<PathBuf>, String> {
    let output = Command::new("git")
        .args(["rev-parse", "--show-toplevel"])
        .current_dir(path)
        .output()
        .map_err(|error| format!("cannot run git: {error}"))?;
    if !output.status.success() {
        if path.ancestors().any(|parent| parent.join(".git").exists()) {
            return Err(format!(
                "git rev-parse --show-toplevel failed: {}",
                String::from_utf8_lossy(&output.stderr).trim()
            ));
        }
        return Ok(None);
    }
    let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if root.is_empty() {
        return Ok(None);
    }

    // On Windows, `Path::canonicalize` commonly gives callers a verbatim
    // `\\?\C:\...` path while Git prints the same directory as `C:/...`.
    // Canonicalize Git's result as well so path-prefix operations compare two
    // paths in the same representation.
    PathBuf::from(&root)
        .canonicalize()
        .map(Some)
        .map_err(|error| format!("cannot access Git repository {root}: {error}"))
}

pub fn branch(path: &Path) -> Result<String, String> {
    let Some(root) = repository_root(path)? else {
        return Ok(String::new());
    };
    Ok(run(&root, &["branch", "--show-current"])?
        .trim()
        .to_string())
}

pub fn protect_workspace(workspace: &Path) -> Result<Option<bool>, String> {
    let workspace = workspace
        .canonicalize()
        .map_err(|error| format!("cannot access workspace {}: {error}", workspace.display()))?;
    let Some(repo) = repository_root(&workspace)? else {
        return Ok(None);
    };
    let relative = workspace.strip_prefix(&repo).map_err(|_| {
        format!(
            "{} is outside Git repository {}",
            workspace.display(),
            repo.display()
        )
    })?;
    let prefix = slash(relative);
    let tincan = if prefix.is_empty() {
        ".tincan".to_string()
    } else {
        format!("{prefix}/.tincan")
    };
    let tracked = run(&repo, &["ls-files", "--", &tincan])?;
    if !tracked.is_empty() {
        return Err(format!(
            "{tincan} already contains tracked files, so Tincan cannot guarantee private storage:\n{}\nremove them from the Git index before running `tincan init`",
            tracked
                .lines()
                .map(|file| format!("  {file}"))
                .collect::<Vec<_>>()
                .join("\n")
        ));
    }

    let exclude = exclude_path(&repo)?;
    let pattern = format!("/{tincan}/");
    let changed = crate::store::ensure_git_excluded(&exclude, &pattern)?;
    verify_ignored(&repo, &format!("{tincan}/config.toml"))?;
    Ok(Some(changed))
}

pub fn workspace_changed_files(workspace: &Path) -> Result<Option<Vec<String>>, String> {
    let workspace = workspace
        .canonicalize()
        .map_err(|error| format!("cannot access workspace {}: {error}", workspace.display()))?;
    if let Some(repo) = repository_root(&workspace)? {
        let relative = slash(workspace.strip_prefix(&repo).unwrap_or(Path::new("")));
        let files = changed_files(&repo)?
            .into_iter()
            .filter_map(|file| strip_workspace_prefix(&file, &relative))
            .collect::<BTreeSet<_>>();
        return Ok(Some(files.into_iter().collect()));
    }

    let repositories = descendant_repositories(&workspace)?;
    if repositories.is_empty() {
        return Ok(None);
    }
    let mut files = BTreeSet::new();
    for repo in repositories {
        let prefix = slash(repo.strip_prefix(&workspace).unwrap_or(&repo));
        for file in changed_files(&repo)? {
            files.insert(if prefix.is_empty() {
                file
            } else {
                format!("{prefix}/{file}")
            });
        }
    }
    Ok(Some(files.into_iter().collect()))
}

fn descendant_repositories(workspace: &Path) -> Result<Vec<PathBuf>, String> {
    let mut repos = Vec::new();
    let mut pending = vec![workspace.to_path_buf()];
    while let Some(directory) = pending.pop() {
        let entries = fs::read_dir(&directory)
            .map_err(|error| format!("cannot read {}: {error}", directory.display()))?;
        for entry in entries {
            let entry = entry.map_err(|error| error.to_string())?;
            let path = entry.path();
            if !path.is_dir() {
                continue;
            }
            let name = entry.file_name();
            if matches!(
                name.to_str(),
                Some(".git" | ".tincan" | "target" | "node_modules" | ".venv")
            ) {
                continue;
            }
            if path.join(".git").exists() {
                repos.push(path);
            } else {
                pending.push(path);
            }
        }
    }
    repos.sort();
    Ok(repos)
}

fn strip_workspace_prefix(file: &str, prefix: &str) -> Option<String> {
    if prefix.is_empty() {
        return Some(file.to_string());
    }
    file.strip_prefix(&(prefix.to_string() + "/"))
        .map(str::to_string)
}

fn changed_files(repo: &Path) -> Result<Vec<String>, String> {
    let mut files = BTreeSet::new();
    let diff = run(repo, &["diff", "--name-only", "HEAD"])
        .or_else(|_| run(repo, &["diff", "--name-only"]))?;
    files.extend(
        diff.lines()
            .map(str::trim)
            .filter(|line| !line.is_empty())
            .map(|line| line.replace('\\', "/")),
    );
    let untracked = run(repo, &["ls-files", "--others", "--exclude-standard"])?;
    files.extend(
        untracked
            .lines()
            .map(str::trim)
            .filter(|line| !line.is_empty())
            .map(|line| line.replace('\\', "/")),
    );
    Ok(files.into_iter().collect())
}

fn exclude_path(repo: &Path) -> Result<PathBuf, String> {
    let value = run(repo, &["rev-parse", "--git-path", "info/exclude"])?;
    let path = PathBuf::from(value.trim());
    Ok(if path.is_absolute() {
        path
    } else {
        repo.join(path)
    })
}

fn verify_ignored(repo: &Path, path: &str) -> Result<(), String> {
    let output = Command::new("git")
        .args(["check-ignore", "--quiet", "--no-index", "--", path])
        .current_dir(repo)
        .output()
        .map_err(|error| format!("cannot run git: {error}"))?;
    if output.status.success() {
        return Ok(());
    }
    if output.status.code() == Some(1) {
        return Err(format!(
            "Git does not ignore {path}; refusing to initialize memory that may be committed. Review repository ignore rules and retry."
        ));
    }
    Err(format!(
        "git check-ignore failed: {}",
        String::from_utf8_lossy(&output.stderr).trim()
    ))
}

fn slash(path: &Path) -> String {
    path.to_string_lossy()
        .replace('\\', "/")
        .trim_matches('/')
        .to_string()
}

fn run(path: &Path, args: &[&str]) -> Result<String, String> {
    let output = Command::new("git")
        .args(args)
        .current_dir(path)
        .output()
        .map_err(|error| format!("cannot run git: {error}"))?;
    if output.status.success() {
        Ok(String::from_utf8_lossy(&output.stdout)
            .trim_end()
            .to_string())
    } else {
        Err(format!(
            "git {} failed: {}",
            args.join(" "),
            String::from_utf8_lossy(&output.stderr).trim()
        ))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn temp(name: &str) -> PathBuf {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        std::env::temp_dir().join(format!("tincan-{name}-{unique}"))
    }

    #[test]
    fn aggregates_changes_from_nested_repositories() {
        let workspace = temp("multi-repo");
        for name in ["api", "web"] {
            let repo = workspace.join(name);
            fs::create_dir_all(repo.join("src")).unwrap();
            run(&repo, &["init", "--quiet"]).unwrap();
            fs::write(repo.join("src/new.txt"), name).unwrap();
        }
        assert_eq!(
            workspace_changed_files(&workspace).unwrap().unwrap(),
            vec!["api/src/new.txt", "web/src/new.txt"]
        );
        fs::remove_dir_all(workspace).unwrap();
    }

    #[test]
    fn protects_a_workspace_below_a_git_root() {
        let repo = temp("nested-private");
        let workspace = repo.join("projects");
        fs::create_dir_all(&workspace).unwrap();
        run(&repo, &["init", "--quiet"]).unwrap();

        assert_eq!(protect_workspace(&workspace).unwrap(), Some(true));
        crate::store::initialize(&workspace).unwrap();
        let status = run(&repo, &["status", "--short", "--untracked-files=all"]).unwrap();
        assert!(
            status.is_empty(),
            "private memory appeared in status: {status}"
        );
        fs::remove_dir_all(repo).unwrap();
    }

    #[test]
    fn repository_root_uses_the_canonical_path_representation() {
        let repo = temp("canonical-root");
        fs::create_dir_all(&repo).unwrap();
        run(&repo, &["init", "--quiet"]).unwrap();

        assert_eq!(
            repository_root(&repo).unwrap(),
            Some(repo.canonicalize().unwrap())
        );
        fs::remove_dir_all(repo).unwrap();
    }
}