//! The git plumbing behind the project panel and the per-turn change //! overview. Everything here blocks, so call it from a background task. use std::{ path::{Path, PathBuf}, process::{Command, Stdio}, }; use serde::{Deserialize, Serialize}; /// Runs git in `path`, returning stdout, or stderr as the error. pub fn git(path: &Path, args: &[&str]) -> Result { run(Command::new("git").arg("-C").arg(path).args(args)) } fn run(command: &mut Command) -> Result { let output = command.stdin(Stdio::null()).output().map_err(|err| err.to_string())?; if output.status.success() { Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } else { Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) } } /// The repository a folder is in, named by its common directory (shared by /// all of its worktrees), and the folder's path inside the checkout. pub fn repository_of(path: &Path) -> Option<(PathBuf, PathBuf)> { let common = git(path, &["rev-parse", "--path-format=absolute", "--git-common-dir"]).ok()?; let top = git(path, &["rev-parse", "--show-toplevel"]).ok()?; let top = PathBuf::from(top.trim()); let inside = path.strip_prefix(&top).map(Path::to_path_buf).unwrap_or_default(); Some((PathBuf::from(common.trim()), inside)) } /// What the project panel shows about a project's git state. #[derive(Clone, Debug, Default, PartialEq)] pub struct GitInfo { pub branch: Option, pub branches: Vec, pub added: u64, pub removed: u64, } /// Reads the branch and the size of the working-tree changes, counting the /// lines of untracked text files as added. `None` outside a repository. pub fn read_info(path: &Path) -> Option { git(path, &["rev-parse", "--is-inside-work-tree"]).ok()?; let branch = git(path, &["symbolic-ref", "--short", "-q", "HEAD"]) .or_else(|_| git(path, &["rev-parse", "--short", "HEAD"])) .ok() .map(|branch| branch.trim().to_string()) .filter(|branch| !branch.is_empty()); let branches = git(path, &["for-each-ref", "--format=%(refname:short)", "refs/heads"]) .map(|out| out.lines().map(str::to_string).collect()) .unwrap_or_default(); let mut numstat = String::new(); if git(path, &["rev-parse", "--verify", "-q", "HEAD"]).is_ok() { numstat.push_str(&git(path, &["diff", "--numstat", "HEAD"]).unwrap_or_default()); } else { // No commits yet: staged against nothing, plus unstaged. numstat.push_str(&git(path, &["diff", "--numstat", "--cached"]).unwrap_or_default()); numstat.push_str(&git(path, &["diff", "--numstat"]).unwrap_or_default()); } let (mut added, mut removed) = (0, 0); for line in numstat.lines() { let mut fields = line.split('\t'); // Binary files report `-`. if let (Some(Ok(plus)), Some(Ok(minus))) = (fields.next().map(str::parse::), fields.next().map(str::parse::)) { added += plus; removed += minus; } } let untracked = git(path, &["ls-files", "--others", "--exclude-standard", "-z"]).unwrap_or_default(); for file in untracked.split('\0').filter(|file| !file.is_empty()).take(5_000) { let file = path.join(file); if file.metadata().is_ok_and(|meta| meta.len() <= 1_048_576) && let Ok(bytes) = std::fs::read(&file) && !bytes.contains(&0) { let lines = bytes.iter().filter(|&&byte| byte == b'\n').count() as u64; added += lines + u64::from(bytes.last().is_some_and(|&byte| byte != b'\n')); } } Some(GitInfo { branch, branches, added, removed }) } /// A branch the branch picker lists. #[derive(Clone, Debug, PartialEq)] pub struct GitRef { /// `main`, or `origin/main` for a remote branch. pub name: String, pub remote: bool, /// Checked out in the folder the refs were read from. pub current: bool, /// The repository's default branch (origin's HEAD). pub default: bool, /// The worktree it's checked out in, when that's another folder. pub worktree: Option, } impl GitRef { /// The badge the branch picker shows next to it. pub fn badge(&self) -> Option<&'static str> { if self.current { Some("current") } else if self.worktree.is_some() { Some("worktree") } else if self.remote { Some("remote") } else if self.default { Some("default") } else { None } } } /// The branches of the repository at `path`, ordered like T3 Code's branch /// picker: the current one, the default one, then local before remote /// branches, each newest commit first. A remote branch is left out when a /// local one has its name. pub fn list_refs(path: &Path) -> Vec { let Ok(out) = git( path, &[ "for-each-ref", "--format=%(refname)%00%(committerdate:unix)%00%(HEAD)%00%(worktreepath)", "refs/heads", "refs/remotes", ], ) else { return Vec::new(); }; let here = git(path, &["rev-parse", "--show-toplevel"]).map(|top| PathBuf::from(top.trim())).ok(); let default = git(path, &["symbolic-ref", "--short", "-q", "refs/remotes/origin/HEAD"]) .ok() .map(|name| name.trim().trim_start_matches("origin/").to_string()); let mut refs: Vec<(i64, GitRef)> = Vec::new(); for line in out.lines() { let mut fields = line.split('\0'); let (Some(refname), Some(date), Some(head), worktree) = (fields.next(), fields.next(), fields.next(), fields.next()) else { continue; }; let (name, remote) = match (refname.strip_prefix("refs/heads/"), refname.strip_prefix("refs/remotes/")) { (Some(name), _) => (name.to_string(), false), (None, Some(name)) if !name.ends_with("/HEAD") => (name.to_string(), true), _ => continue, }; let worktree = worktree.filter(|path| !path.is_empty()).map(PathBuf::from).filter(|path| path.exists()); let current = head == "*" || worktree.is_some() && worktree == here; let is_default = default.as_deref().is_some_and(|default| { if remote { name.strip_prefix("origin/") == Some(default) } else { name == default } }); refs.push(( date.parse().unwrap_or(0), GitRef { name, remote, current, default: is_default, worktree: worktree.filter(|_| !current) }, )); } let locals: Vec = refs.iter().filter(|(_, r)| !r.remote).map(|(_, r)| r.name.clone()).collect(); refs.retain(|(_, r)| !r.remote || !r.name.strip_prefix("origin/").is_some_and(|name| locals.iter().any(|local| local == name))); refs.sort_by(|(a_date, a), (b_date, b)| { (!a.current, !a.default, a.remote, -a_date, &a.name).cmp(&(!b.current, !b.default, b.remote, -b_date, &b.name)) }); refs.into_iter().map(|(_, r)| r).collect() } /// Records the whole working tree, untracked files included, as a git tree /// and returns its id. It stages into `index`, a private index file, so the /// user's own staging area is never touched; the blobs land in the repository /// as unreferenced objects that `git gc` cleans up. pub fn snapshot(cwd: &Path, index: &Path) -> Option { let git_dir = PathBuf::from(git(cwd, &["rev-parse", "--absolute-git-dir"]).ok()?.trim()); if !index.exists() { // Seeding from the real index lets git skip rehashing unchanged files. let _ = std::fs::copy(git_dir.join("index"), index); } let with_index = |args: &[&str]| { run(Command::new("git").arg("-C").arg(cwd).args(args).env("GIT_INDEX_FILE", index)) }; with_index(&["add", "-A", "--", ":/"]).ok()?; let tree = with_index(&["write-tree"]).ok()?; Some(tree.trim().to_string()) } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct FileChange { pub path: String, /// `None` for binary files. pub added: Option, pub removed: Option, } /// The files one turn changed: the difference between two snapshots. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct TurnChanges { pub base: String, pub head: String, pub files: Vec, } impl TurnChanges { pub fn added(&self) -> u64 { self.files.iter().filter_map(|file| file.added).sum() } pub fn removed(&self) -> u64 { self.files.iter().filter_map(|file| file.removed).sum() } } pub fn changes_between(cwd: &Path, base: &str, head: &str) -> Option { let out = git(cwd, &["diff-tree", "-r", "-z", "--numstat", "--no-renames", base, head]).ok()?; let mut files = Vec::new(); for record in out.split('\0').filter(|record| !record.is_empty()) { let mut fields = record.splitn(3, '\t'); let (Some(added), Some(removed), Some(path)) = (fields.next(), fields.next(), fields.next()) else { continue; }; files.push(FileChange { path: path.to_string(), added: added.parse().ok(), removed: removed.parse().ok(), }); } files.sort_by(|a, b| a.path.cmp(&b.path)); Some(TurnChanges { base: base.into(), head: head.into(), files }) } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LineKind { Hunk, Context, Added, Removed, } #[derive(Clone, Debug)] pub struct DiffLine { pub kind: LineKind, pub text: String, /// Where the line sits in the old and the new file. Added lines have no /// old number, removed ones no new number, hunk rows neither. pub old_line: Option, pub new_line: Option, } #[derive(Clone, Debug)] pub struct FileDiff { pub path: String, pub lines: Vec, pub binary: bool, } /// The unified diff between two snapshots, optionally for one path. /// `ignore_whitespace` leaves out whitespace-only edits, and with them the /// files that changed nothing else. pub fn diff_between( cwd: &Path, base: &str, head: &str, path: Option<&str>, ignore_whitespace: bool, ) -> Result, String> { let mut args = vec!["diff", "--no-color", "--no-ext-diff", "--no-renames", "-U3"]; if ignore_whitespace { args.push("--ignore-all-space"); } args.extend([base, head]); if let Some(path) = path { args.extend(["--", path]); } let mut files = parse_diff(&git(cwd, &args)?); if ignore_whitespace { // Newer gits drop such a file; older ones keep its header, hunkless. files.retain(|file| file.binary || !file.lines.is_empty()); } Ok(files) } /// The first old and new line of a hunk, from `@@ -a,b +c,d @@ context`. fn parse_hunk(line: &str) -> Option<(u32, u32)> { let mut ranges = line.strip_prefix("@@ ")?.split(' '); let start = |range: Option<&str>, sign: char| range?.strip_prefix(sign)?.split(',').next()?.parse().ok(); Some((start(ranges.next(), '-')?, start(ranges.next(), '+')?)) } fn parse_diff(text: &str) -> Vec { let mut files: Vec = Vec::new(); // The numbers the next old and new line will have. let (mut old, mut new) = (0, 0); for line in text.lines() { if let Some(rest) = line.strip_prefix("diff --git ") { // `a/path b/path`: take the new side. let path = rest.rsplit_once(" b/").map(|(_, path)| path).unwrap_or(rest); files.push(FileDiff { path: path.to_string(), lines: Vec::new(), binary: false }); continue; } let Some(file) = files.last_mut() else { continue }; let (kind, text, old_line, new_line) = if line.starts_with("@@") { (old, new) = parse_hunk(line).unwrap_or_default(); (LineKind::Hunk, line, None, None) } else if line.starts_with("Binary files") { file.binary = true; continue; } else if file.lines.is_empty() && (line.starts_with("---") || line.starts_with("+++")) { continue; } else if let Some(text) = line.strip_prefix('+') { new += 1; (LineKind::Added, text, None, Some(new - 1)) } else if let Some(text) = line.strip_prefix('-') { old += 1; (LineKind::Removed, text, Some(old - 1), None) } else if let Some(text) = line.strip_prefix(' ') { (old, new) = (old + 1, new + 1); (LineKind::Context, text, Some(old - 1), Some(new - 1)) } else { // Headers: index, mode and new/deleted file lines. continue; }; file.lines.push(DiffLine { kind, text: text.to_string(), old_line, new_line }); } files } #[cfg(test)] mod tests { use super::{LineKind, parse_diff, parse_hunk}; #[::core::prelude::v1::test] fn parses_hunk_headers() { assert_eq!(parse_hunk("@@ -1,2 +3,4 @@ fn a() {"), Some((1, 3))); assert_eq!(parse_hunk("@@ -0,0 +1 @@"), Some((0, 1))); assert_eq!(parse_hunk("@@ -7 +7 @@"), Some((7, 7))); assert_eq!(parse_hunk("@@@ -1,2 -1,2 +1,3 @@@"), None); } #[::core::prelude::v1::test] fn numbers_lines() { let diff = "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -10,4 +12,5 @@ fn a() {\n keep\n-gone\n+one\n+two\n keep\n@@ -40 +43 @@\n-x\n+y\n"; let files = parse_diff(diff); let numbers: Vec<_> = files[0].lines.iter().map(|line| (line.old_line, line.new_line)).collect(); assert_eq!( numbers, [ (None, None), (Some(10), Some(12)), (Some(11), None), (None, Some(13)), (None, Some(14)), (Some(12), Some(15)), (None, None), (Some(40), None), (None, Some(43)), ] ); assert_eq!(files[0].lines[0].text, "@@ -10,4 +12,5 @@ fn a() {"); } #[::core::prelude::v1::test] fn parses_unified_diff() { let diff = "diff --git a/src/a.rs b/src/a.rs\nindex 1..2 100644\n--- a/src/a.rs\n+++ b/src/a.rs\n@@ -1,2 +1,2 @@\n fn a() {}\n-old\n+new\n"; let files = parse_diff(diff); assert_eq!(files.len(), 1); assert_eq!(files[0].path, "src/a.rs"); let kinds: Vec<_> = files[0].lines.iter().map(|line| line.kind).collect(); assert_eq!(kinds, [LineKind::Hunk, LineKind::Context, LineKind::Removed, LineKind::Added]); } } /// One changed file, for the commit dialog. #[derive(Clone, Debug, PartialEq)] pub struct StatusFile { pub path: String, pub added: Option, pub removed: Option, } /// What T3 Code's git actions need to know about a checkout. #[derive(Clone, Debug, Default, PartialEq)] pub struct GitStatus { pub branch: Option, pub files: Vec, /// The branch it tracks, like `origin/main`. pub upstream: Option, pub ahead: u32, pub behind: u32, pub has_origin: bool, /// The repository's default branch, like `main`. pub default_branch: Option, /// Commits on the branch that the default branch doesn't have. pub ahead_of_default: u32, } impl GitStatus { pub fn detached(&self) -> bool { self.branch.is_none() } pub fn has_changes(&self) -> bool { !self.files.is_empty() } pub fn on_default(&self) -> bool { self.branch.is_some() && self.branch == self.default_branch } } /// Reads a checkout's branch, changed files and how it stands against its /// upstream and the default branch. `None` outside a repository. pub fn status(path: &Path) -> Option { git(path, &["rev-parse", "--is-inside-work-tree"]).ok()?; let branch = git(path, &["symbolic-ref", "--short", "-q", "HEAD"]).ok().map(|out| out.trim().to_string()).filter(|b| !b.is_empty()); let mut files: Vec = Vec::new(); let porcelain = git(path, &["status", "--porcelain", "-z", "--untracked-files=all"]).unwrap_or_default(); let mut entries = porcelain.split('\0').filter(|entry| !entry.is_empty()); while let Some(entry) = entries.next() { if entry.len() < 4 { continue; } let code = &entry[..2]; let file = entry[3..].to_string(); // A rename lists its old path next. if code.starts_with('R') || code.starts_with('C') { entries.next(); } files.push(StatusFile { path: file, added: None, removed: None }); } let has_head = git(path, &["rev-parse", "--verify", "-q", "HEAD"]).is_ok(); if has_head { for line in git(path, &["diff", "--numstat", "HEAD"]).unwrap_or_default().lines() { let mut fields = line.splitn(3, '\t'); let (Some(plus), Some(minus), Some(file)) = (fields.next(), fields.next(), fields.next()) else { continue }; if let Some(entry) = files.iter_mut().find(|entry| entry.path == file) { entry.added = plus.parse().ok(); entry.removed = minus.parse().ok(); } } } for entry in files.iter_mut().filter(|entry| entry.added.is_none()) { let full = path.join(&entry.path); if full.metadata().is_ok_and(|meta| meta.is_file() && meta.len() <= 1_048_576) && let Ok(bytes) = std::fs::read(&full) && !bytes.contains(&0) { entry.added = Some(bytes.iter().filter(|&&byte| byte == b'\n').count() as u64); entry.removed = Some(0); } } let upstream = git(path, &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]) .ok() .map(|out| out.trim().to_string()) .filter(|upstream| !upstream.is_empty()); let (mut ahead, mut behind) = (0, 0); if upstream.is_some() && let Ok(counts) = git(path, &["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]) { let mut counts = counts.split_whitespace().map(|count| count.parse().unwrap_or(0)); ahead = counts.next().unwrap_or(0); behind = counts.next().unwrap_or(0); } let has_origin = git(path, &["remote"]).is_ok_and(|remotes| remotes.lines().any(|remote| remote == "origin")); let default_branch = git(path, &["symbolic-ref", "--short", "-q", "refs/remotes/origin/HEAD"]) .ok() .map(|name| name.trim().trim_start_matches("origin/").to_string()) .filter(|name| !name.is_empty()) .or_else(|| { ["main", "master"] .into_iter() .find(|name| git(path, &["rev-parse", "--verify", "-q", &format!("refs/heads/{name}")]).is_ok()) .map(str::to_string) }); let ahead_of_default = match (&default_branch, has_head) { (Some(default), true) => { let base = if has_origin && git(path, &["rev-parse", "--verify", "-q", &format!("refs/remotes/origin/{default}")]).is_ok() { format!("origin/{default}") } else { default.clone() }; git(path, &["rev-list", "--count", &format!("{base}..HEAD")]).ok().and_then(|out| out.trim().parse().ok()).unwrap_or(0) } _ => 0, }; Some(GitStatus { branch, files, upstream, ahead, behind, has_origin, default_branch, ahead_of_default }) } /// What's staged, as the commit message prompt reads it: the file list and /// the patch. pub fn staged(path: &Path) -> (String, String) { (git(path, &["diff", "--cached", "--stat"]).unwrap_or_default(), git(path, &["diff", "--cached"]).unwrap_or_default()) }