use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct RepoPaths {
pub git_dir: PathBuf,
pub common_dir: PathBuf,
pub toplevel: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorktreeKind {
Main,
Linked { main_root: Option<PathBuf> },
NotGit,
}
pub fn repo_paths(frame_dir: &Path) -> Option<RepoPaths> {
let root = frame_dir.parent()?;
let output = Command::new("git")
.arg("-C")
.arg(root)
.args([
"rev-parse",
"--absolute-git-dir",
"--git-common-dir",
"--show-toplevel",
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8(output.stdout).ok()?;
let mut lines = text.lines();
let mut next = || -> Option<PathBuf> {
let raw = lines.next()?.trim();
if raw.is_empty() {
return None;
}
let path = PathBuf::from(raw);
let abs = if path.is_absolute() {
path
} else {
root.join(path)
};
abs.canonicalize().ok()
};
Some(RepoPaths {
git_dir: next()?,
common_dir: next()?,
toplevel: next()?,
})
}
pub fn git_common_dir(frame_dir: &Path) -> Option<PathBuf> {
repo_paths(frame_dir).map(|p| p.common_dir)
}
pub fn worktree_kind(frame_dir: &Path) -> WorktreeKind {
let Some(paths) = repo_paths(frame_dir) else {
return WorktreeKind::NotGit;
};
if paths.git_dir == paths.common_dir {
return WorktreeKind::Main;
}
let main_root = paths.common_dir.parent().filter(|root| {
root.join(".git")
.canonicalize()
.is_ok_and(|g| g == paths.common_dir)
});
WorktreeKind::Linked {
main_root: main_root.map(|r| r.to_path_buf()),
}
}
pub fn main_worktree_frame_dir(frame_dir: &Path) -> Option<PathBuf> {
let WorktreeKind::Linked { main_root } = worktree_kind(frame_dir) else {
return None; };
let main_root = main_root?;
let paths = repo_paths(frame_dir)?;
let rel = frame_dir
.canonicalize()
.ok()?
.strip_prefix(&paths.toplevel)
.ok()?
.to_path_buf();
let candidate = main_root.join(rel);
candidate.is_dir().then_some(candidate)
}
fn git_paths(toplevel: &Path, args: &[&str], rel_paths: &[String]) -> Option<Vec<String>> {
let output = Command::new("git")
.arg("-C")
.arg(toplevel)
.args(args)
.arg("--")
.args(rel_paths)
.output()
.ok()?;
if output.status.code() == Some(128) {
return None;
}
let text = String::from_utf8(output.stdout).ok()?;
Some(text.lines().map(|l| l.trim().to_string()).collect())
}
pub fn tracked_paths(toplevel: &Path, rel_paths: &[String]) -> Option<Vec<String>> {
git_paths(toplevel, &["ls-files", "--cached"], rel_paths)
}
pub fn ignored_paths(toplevel: &Path, rel_paths: &[String]) -> Option<Vec<String>> {
git_paths(toplevel, &["check-ignore"], rel_paths)
}
fn modified_paths(toplevel: &Path, rel_paths: &[String]) -> Option<Vec<String>> {
git_paths(toplevel, &["diff", "--name-only"], rel_paths)
}
const OPERATION_MARKERS: [&str; 6] = [
"rebase-merge", "rebase-apply", "MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "BISECT_LOG", ];
pub fn operation_in_progress(frame_dir: &Path) -> bool {
let Some(paths) = repo_paths(frame_dir) else {
return false;
};
OPERATION_MARKERS
.iter()
.any(|marker| paths.git_dir.join(marker).exists())
}
pub fn index_clean_paths(frame_dir: &Path, abs_paths: &[PathBuf]) -> Vec<PathBuf> {
let Some(paths) = repo_paths(frame_dir) else {
return Vec::new();
};
let relativized: Vec<(String, PathBuf)> = abs_paths
.iter()
.filter_map(|p| {
let canon = p.canonicalize().ok()?;
let rel = canon.strip_prefix(&paths.toplevel).ok()?.to_str()?;
Some((rel.to_string(), p.clone()))
})
.collect();
if relativized.is_empty() {
return Vec::new();
}
let rels: Vec<String> = relativized.iter().map(|(rel, _)| rel.clone()).collect();
let (Some(tracked), Some(modified)) = (
tracked_paths(&paths.toplevel, &rels),
modified_paths(&paths.toplevel, &rels),
) else {
return Vec::new();
};
relativized
.into_iter()
.filter(|(rel, _)| tracked.contains(rel) && !modified.contains(rel))
.map(|(_, abs)| abs)
.collect()
}
#[cfg(test)]
pub(crate) mod testutil {
use std::path::{Path, PathBuf};
use std::process::Command;
fn git(cwd: &Path, args: &[&str]) -> bool {
Command::new("git")
.current_dir(cwd)
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub(crate) fn repo_with_worktree(tmp: &Path) -> Option<(PathBuf, PathBuf)> {
let main = tmp.join("main");
std::fs::create_dir_all(&main).ok()?;
if !git(&main, &["init", "-q"]) {
return None;
}
let committed = git(
&main,
&[
"-c",
"user.name=frame-test",
"-c",
"user.email=frame@test.invalid",
"commit",
"-q",
"--allow-empty",
"-m",
"init",
],
);
if !committed {
return None;
}
let wt = tmp.join("wt");
if !git(&main, &["worktree", "add", "-q", "--detach", wt.to_str()?]) {
return None;
}
let main_frame = main.join("frame");
let wt_frame = wt.join("frame");
std::fs::create_dir_all(&main_frame).ok()?;
std::fs::create_dir_all(&wt_frame).ok()?;
Some((main_frame, wt_frame))
}
pub(crate) fn repo_with_committed_track(tmp: &Path) -> Option<PathBuf> {
let root = tmp.join("repo");
let tracks = root.join("frame").join("tracks");
std::fs::create_dir_all(&tracks).ok()?;
if !git(&root, &["init", "-q"]) {
return None;
}
std::fs::write(tracks.join("main.md"), "# Main\n\n## Done\n").ok()?;
if !git(&root, &["add", "."]) {
return None;
}
let committed = git(
&root,
&[
"-c",
"user.name=frame-test",
"-c",
"user.email=frame@test.invalid",
"commit",
"-q",
"-m",
"init",
],
);
committed.then(|| root.join("frame"))
}
pub(crate) fn restore(root: &Path, rel: &str) -> bool {
git(root, &["restore", "--", rel])
}
pub(crate) fn commit_all(root: &Path) -> bool {
git(root, &["add", "-A"])
&& git(
root,
&[
"-c",
"user.name=frame-test",
"-c",
"user.email=frame@test.invalid",
"commit",
"-q",
"-m",
"wip",
],
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn worktree_kind_distinguishes_main_from_linked() {
let tmp = TempDir::new().unwrap();
let Some((main_frame, wt_frame)) = testutil::repo_with_worktree(tmp.path()) else {
return; };
assert_eq!(worktree_kind(&main_frame), WorktreeKind::Main);
match worktree_kind(&wt_frame) {
WorktreeKind::Linked { main_root } => {
let expected = main_frame.parent().unwrap().canonicalize().unwrap();
assert_eq!(main_root.map(|r| r.canonicalize().unwrap()), Some(expected));
}
other => panic!("expected Linked, got {other:?}"),
}
}
#[test]
fn main_worktree_frame_dir_resolves_only_from_a_linked_worktree() {
let tmp = TempDir::new().unwrap();
let Some((main_frame, wt_frame)) = testutil::repo_with_worktree(tmp.path()) else {
return;
};
assert_eq!(main_worktree_frame_dir(&main_frame), None);
assert_eq!(
main_worktree_frame_dir(&wt_frame).map(|p| p.canonicalize().unwrap()),
Some(main_frame.canonicalize().unwrap())
);
}
#[test]
fn non_git_project_has_no_repo_paths() {
let tmp = TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
std::fs::create_dir_all(&frame_dir).unwrap();
if repo_paths(&frame_dir).is_none() {
assert_eq!(worktree_kind(&frame_dir), WorktreeKind::NotGit);
assert_eq!(git_common_dir(&frame_dir), None);
assert_eq!(main_worktree_frame_dir(&frame_dir), None);
assert!(!operation_in_progress(&frame_dir));
assert!(index_clean_paths(&frame_dir, &[frame_dir.join("tracks/main.md")]).is_empty());
}
}
#[test]
fn operation_in_progress_detects_file_and_directory_markers() {
let tmp = TempDir::new().unwrap();
let Some(frame_dir) = testutil::repo_with_committed_track(tmp.path()) else {
return; };
assert!(!operation_in_progress(&frame_dir), "settled repo");
let git_dir = repo_paths(&frame_dir).unwrap().git_dir;
std::fs::write(git_dir.join("MERGE_HEAD"), "").unwrap();
assert!(operation_in_progress(&frame_dir), "merge in progress");
std::fs::remove_file(git_dir.join("MERGE_HEAD")).unwrap();
assert!(!operation_in_progress(&frame_dir), "merge finished");
std::fs::create_dir(git_dir.join("rebase-merge")).unwrap();
assert!(operation_in_progress(&frame_dir), "rebase in progress");
}
#[test]
fn index_clean_paths_separates_git_writes_from_editor_writes() {
let tmp = TempDir::new().unwrap();
let Some(frame_dir) = testutil::repo_with_committed_track(tmp.path()) else {
return; };
let root = frame_dir.parent().unwrap().to_path_buf();
let track = frame_dir.join("tracks").join("main.md");
assert_eq!(
index_clean_paths(&frame_dir, std::slice::from_ref(&track)),
vec![track.clone()],
"unmodified tracked file"
);
std::fs::write(&track, "# Main\n\n## Done\n\n- [x] `M-1` Ticked\n").unwrap();
assert!(
index_clean_paths(&frame_dir, std::slice::from_ref(&track)).is_empty(),
"edited file must not read as a git write"
);
assert!(testutil::restore(&root, "frame/tracks/main.md"));
assert_eq!(
index_clean_paths(&frame_dir, std::slice::from_ref(&track)),
vec![track],
"restored file reads as a git write"
);
let untracked = frame_dir.join("tracks").join("new.md");
std::fs::write(&untracked, "# New\n").unwrap();
assert!(
index_clean_paths(&frame_dir, &[untracked]).is_empty(),
"untracked file"
);
}
}