Skip to main content

git_pincer/
git.rs

1//! A thin wrapper around the native git CLI.
2//!
3//! All git interaction is done by shelling out (the same route lazygit / IDEA
4//! take), so everything the user has already configured is inherited:
5//! credentials, hooks, merge strategies, rerere, and so on. Arguments are
6//! always passed as arrays and never go through a shell, ruling out injection
7//! by construction; commands that may create commits run with
8//! `GIT_EDITOR=true` so no editor pops up and hangs the TUI.
9
10use std::path::{Path, PathBuf};
11use std::process::{Command, ExitStatus, Output};
12
13/// Git command execution failed error
14#[derive(Debug, thiserror::Error)]
15pub enum GitError {
16    /// Cannot find the git command
17    #[error("Cannot find the git executable file, please confirm it is installed and in the PATH.")]
18    NotFound,
19    /// The specified directory is not a Git repository
20    #[error("The specified path is not a Git repository")]
21    NotARepo,
22    /// The git command execution failed.
23    #[error("git {cmd} execution failed: {stderr}")]
24    Failed {
25        /// Failed subcommands (excluding git prefix)
26        cmd: String,
27        /// Error message of command output
28        stderr: String,
29    },
30    /// Underlying IO error
31    #[error("IO error: {0}")]
32    Io(#[from] std::io::Error),
33}
34
35/// 仓库状态
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum RepoState {
38    /// 无进行中的合并操作
39    Clean,
40    /// merge 进行中(存在 MERGE_HEAD)
41    Merging,
42    /// rebase 进行中(存在 rebase-merge,或不带 applying 标记的 rebase-apply)
43    Rebasing,
44    /// cherry-pick 进行中(存在 CHERRY_PICK_HEAD)
45    CherryPicking,
46    /// revert 进行中(存在 REVERT_HEAD)
47    Reverting,
48    /// git am 打补丁进行中(存在 rebase-apply/applying)
49    Am,
50}
51
52impl RepoState {
53    /// 对应的 git 子命令名(用于 `--continue/--abort` 与提示信息)。
54    pub fn op_name(self) -> &'static str {
55        match self {
56            RepoState::Clean => "clean",
57            RepoState::Merging => "merge",
58            RepoState::Rebasing => "rebase",
59            RepoState::CherryPicking => "cherry-pick",
60            RepoState::Reverting => "revert",
61            RepoState::Am => "am",
62        }
63    }
64}
65
66/// 一个处于冲突状态的文件及其在 index 中的 stage 分布。
67///
68/// 各 stage 记录 blob oid(缺失该 stage 时为 None),
69/// 供 [`Git::read_blobs`] 单进程批量读取内容。
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct ConflictedFile {
72    /// 相对仓库根目录的路径
73    pub path: String,
74    /// stage 1(base)的 blob oid;add/add 冲突时缺失
75    pub base: Option<String>,
76    /// stage 2(ours)的 blob oid;对方删除本方修改时才缺失本方
77    pub ours: Option<String>,
78    /// stage 3(theirs)的 blob oid
79    pub theirs: Option<String>,
80}
81
82/// 仓库体征:主菜单 RPG 状态面板展示的数据,打开菜单时查询一次。
83#[derive(Debug, Clone)]
84pub struct RepoVitals {
85    /// 当前分支名(detached HEAD 时为 "HEAD")
86    pub branch: String,
87    /// 工作区 + 暂存区改动条目数
88    pub changes: usize,
89    /// stash 条目数
90    pub stashes: usize,
91    /// 领先上游的提交数;未设置上游分支时为 None
92    pub ahead: Option<usize>,
93    /// HEAD 可达的提交总数(空仓库为 0)
94    pub level: usize,
95}
96
97/// git 调用上下文:锚定仓库根目录,verbose 时回显执行的命令。
98pub struct Git {
99    /// 仓库根目录;所有命令经 `-C` 锚定,路径统一为根相对
100    top: PathBuf,
101    /// 是否回显执行的 git 命令(-v)
102    verbose: bool,
103}
104
105/// 将进程启动错误归一化为 GitError。
106fn spawn_err(e: std::io::Error) -> GitError {
107    if e.kind() == std::io::ErrorKind::NotFound {
108        GitError::NotFound
109    } else {
110        GitError::Io(e)
111    }
112}
113
114/// 需要从子进程环境中清除的 git 变量。
115///
116/// 当本工具从 git 钩子里被间接调起时(如 pre-commit 里跑测试),
117/// 外层 git 会注入这些指向宿主仓库的路径;嵌套 git 调用若继承它们,
118/// 会被劫持到错误的仓库,必须清掉。
119const SCRUBBED_GIT_ENV: [&str; 4] = [
120    "GIT_DIR",
121    "GIT_WORK_TREE",
122    "GIT_INDEX_FILE",
123    "GIT_OBJECT_DIRECTORY",
124];
125
126/// 构造锚定到指定目录的基础 git 命令:清理宿主 git 环境 + 禁用交互编辑器。
127fn base_git(dir: &Path) -> Command {
128    let mut cmd = Command::new("git");
129    cmd.arg("-C").arg(dir).env("GIT_EDITOR", "true");
130    for var in SCRUBBED_GIT_ENV {
131        cmd.env_remove(var);
132    }
133    cmd
134}
135
136impl Git {
137    /// 从指定目录探测 git 仓库根并构造上下文。
138    pub fn discover(dir: &Path, verbose: bool) -> Result<Self, GitError> {
139        let out = base_git(dir)
140            .args(["rev-parse", "--show-toplevel"])
141            .output()
142            .map_err(spawn_err)?;
143        if !out.status.success() {
144            return Err(GitError::NotARepo);
145        }
146        let top = String::from_utf8_lossy(&out.stdout).trim().to_owned();
147        Ok(Self {
148            top: PathBuf::from(top),
149            verbose,
150        })
151    }
152
153    /// 仓库根目录。
154    pub fn top(&self) -> &Path {
155        &self.top
156    }
157
158    /// 执行 git 命令并捕获输出(不检查退出码,交给调用方判断)。
159    pub fn run(&self, args: &[&str]) -> Result<Output, GitError> {
160        if self.verbose {
161            eprintln!("[git] git {}", args.join(" "));
162        }
163        base_git(&self.top).args(args).output().map_err(spawn_err)
164    }
165
166    /// 执行 git 命令,非零退出码视为错误。
167    pub fn run_ok(&self, args: &[&str]) -> Result<Output, GitError> {
168        let out = self.run(args)?;
169        if !out.status.success() {
170            return Err(GitError::Failed {
171                cmd: args.join(" "),
172                stderr: String::from_utf8_lossy(&out.stderr).trim().to_owned(),
173            });
174        }
175        Ok(out)
176    }
177
178    /// 以透传模式执行 git:输出直接接到用户终端,
179    /// 用于发起 merge / rebase / pull(保留 git 自身的进度显示)。
180    pub fn run_inherit(&self, args: &[&str]) -> Result<ExitStatus, GitError> {
181        if self.verbose {
182            eprintln!("[git] git {}", args.join(" "));
183        }
184        base_git(&self.top).args(args).status().map_err(spawn_err)
185    }
186
187    /// 探测仓库当前的合并状态。
188    pub fn state(&self) -> Result<RepoState, GitError> {
189        let out = self.run_ok(&["rev-parse", "--git-dir"])?;
190        let raw = String::from_utf8_lossy(&out.stdout).trim().to_owned();
191        // --git-dir 可能返回相对路径(相对仓库根)
192        let git_dir = {
193            let p = PathBuf::from(&raw);
194            if p.is_absolute() { p } else { self.top.join(p) }
195        };
196        // rebase 必须先于 cherry-pick 判定:交互式 rebase 内部逐个重放提交,
197        // 冲突时也会留下 CHERRY_PICK_HEAD,但收尾命令是 rebase --continue
198        if git_dir.join("rebase-apply").exists() {
199            // rebase-apply/applying 是 git am 的标记(git 自身也以此区分两者)
200            if git_dir.join("rebase-apply/applying").exists() {
201                Ok(RepoState::Am)
202            } else {
203                Ok(RepoState::Rebasing)
204            }
205        } else if git_dir.join("rebase-merge").exists() {
206            Ok(RepoState::Rebasing)
207        } else if git_dir.join("CHERRY_PICK_HEAD").exists() {
208            Ok(RepoState::CherryPicking)
209        } else if git_dir.join("REVERT_HEAD").exists() {
210            Ok(RepoState::Reverting)
211        } else if git_dir.join("MERGE_HEAD").exists() {
212            Ok(RepoState::Merging)
213        } else {
214            Ok(RepoState::Clean)
215        }
216    }
217
218    /// 列出所有处于冲突状态的文件。
219    pub fn conflicted_files(&self) -> Result<Vec<ConflictedFile>, GitError> {
220        let out = self.run_ok(&["ls-files", "-u", "-z"])?;
221        Ok(parse_ls_files_unmerged(&String::from_utf8_lossy(
222            &out.stdout,
223        )))
224    }
225
226    /// 读取冲突文件某个 stage 的完整内容(1=base,2=ours,3=theirs)。
227    pub fn read_stage(&self, path: &str, stage: u8) -> Result<Vec<u8>, GitError> {
228        let spec = format!(":{stage}:{path}");
229        Ok(self.run_ok(&["show", &spec])?.stdout)
230    }
231
232    /// 用单个 `git cat-file --batch` 进程批量读取 blob,按请求顺序返回。
233    ///
234    /// 逐文件 `git show` 每个 stage 都要 spawn 一次进程(实测 ~12-16ms),
235    /// 冲突文件多时进入 TUI 前的等待明显;这里改为请求-应答式复用一个
236    /// 子进程,进程开销从 O(3N) 降为 O(1)。
237    pub fn read_blobs(&self, oids: &[&str]) -> Result<Vec<Vec<u8>>, GitError> {
238        use std::io::{BufRead, BufReader, Read, Write};
239
240        if oids.is_empty() {
241            return Ok(Vec::new());
242        }
243        if self.verbose {
244            eprintln!("[git] git cat-file --batch ({} blobs)", oids.len());
245        }
246        let mut child = base_git(&self.top)
247            .args(["cat-file", "--batch"])
248            .stdin(std::process::Stdio::piped())
249            .stdout(std::process::Stdio::piped())
250            .stderr(std::process::Stdio::null())
251            .spawn()
252            .map_err(spawn_err)?;
253        // 逐条请求-应答,避免双向管道同时写满导致的死锁
254        let mut stdin = child.stdin.take().ok_or(GitError::NotFound)?;
255        let mut stdout = BufReader::new(child.stdout.take().ok_or(GitError::NotFound)?);
256        let failed = |detail: String| GitError::Failed {
257            cmd: "cat-file --batch".to_owned(),
258            stderr: detail,
259        };
260
261        let mut blobs = Vec::with_capacity(oids.len());
262        for oid in oids {
263            stdin.write_all(format!("{oid}\n").as_bytes())?;
264            stdin.flush()?;
265            // 应答头:`<oid> <type> <size>` 或 `<oid> missing`
266            let mut header = String::new();
267            stdout.read_line(&mut header)?;
268            let size: usize = header
269                .split_whitespace()
270                .nth(2)
271                .and_then(|s| s.parse().ok())
272                .ok_or_else(|| failed(format!("对象 {oid} 不可读: {}", header.trim())))?;
273            let mut content = vec![0u8; size];
274            stdout.read_exact(&mut content)?;
275            // 每个对象后跟一个换行分隔符
276            stdout.read_exact(&mut [0u8; 1])?;
277            blobs.push(content);
278        }
279        drop(stdin);
280        child.wait()?;
281        Ok(blobs)
282    }
283
284    /// 列出可作为 merge / rebase 目标的分支:本地 + 远程跟踪,
285    /// 排除当前分支与 HEAD 符号引用。
286    pub fn list_branches(&self) -> Result<Vec<String>, GitError> {
287        let current = {
288            let out = self.run_ok(&["branch", "--show-current"])?;
289            String::from_utf8_lossy(&out.stdout).trim().to_owned()
290        };
291        let queries: [&[&str]; 2] = [
292            &["branch", "--format=%(refname:short)"],
293            &["branch", "-r", "--format=%(refname:short)"],
294        ];
295        let mut branches = Vec::new();
296        for args in queries {
297            let out = self.run_ok(args)?;
298            for line in String::from_utf8_lossy(&out.stdout).lines() {
299                let name = line.trim();
300                if name.is_empty() || name == current || name.contains("HEAD") {
301                    continue;
302                }
303                branches.push(name.to_owned());
304            }
305        }
306        Ok(branches)
307    }
308
309    /// 最近提交列表(`--oneline` 行,首列为短 hash),提交选择器用。
310    ///
311    /// `others_only` 为 true 时只列不在当前分支上的提交(cherry-pick 候选),
312    /// 否则列当前分支的最近提交(revert 候选)。
313    /// 空仓库等无提交可列的场景返回空列表而非报错。
314    pub fn recent_commits(&self, others_only: bool, limit: usize) -> Result<Vec<String>, GitError> {
315        let n = format!("-n{limit}");
316        let mut args = vec!["log", "--oneline", &n];
317        if others_only {
318            args.extend(["--all", "--not", "HEAD"]);
319        }
320        let out = self.run(&args)?;
321        if !out.status.success() {
322            return Ok(Vec::new());
323        }
324        Ok(String::from_utf8_lossy(&out.stdout)
325            .lines()
326            .map(str::to_owned)
327            .collect())
328    }
329
330    /// 探测仓库体征:分支、改动数、贮藏数、待推送数与提交总数。
331    ///
332    /// 五个查询彼此独立,而每次 git 进程 spawn 约 12-16ms,串行累计的
333    /// 延迟在打开菜单时可感知,因此用作用域线程并行执行,总耗时约等于
334    /// 最慢的一次;`ahead` / `level` 在无上游 / 空仓库时查询会非零退出,
335    /// 分别归一化为 None / 0 而非报错。
336    pub fn vitals(&self) -> Result<RepoVitals, GitError> {
337        // 汇合子线程;查询闭包只返回 Result 不会 panic,此分支仅为完备
338        fn joined<T>(handle: std::thread::ScopedJoinHandle<'_, T>) -> Result<T, GitError> {
339            handle.join().map_err(|_| GitError::Failed {
340                cmd: "vitals".to_owned(),
341                stderr: "worker thread panicked".to_owned(),
342            })
343        }
344        let (branch, changes, stashes, ahead, level) = std::thread::scope(|s| {
345            let branch = s.spawn(|| self.run_ok(&["branch", "--show-current"]));
346            let changes = s.spawn(|| self.run_ok(&["status", "--porcelain"]));
347            let stashes = s.spawn(|| self.run_ok(&["stash", "list"]));
348            let ahead = s.spawn(|| self.run(&["rev-list", "--count", "@{upstream}..HEAD"]));
349            let level = s.spawn(|| self.run(&["rev-list", "--count", "HEAD"]));
350            (
351                joined(branch),
352                joined(changes),
353                joined(stashes),
354                joined(ahead),
355                joined(level),
356            )
357        });
358
359        let branch = {
360            let name = String::from_utf8_lossy(&branch??.stdout).trim().to_owned();
361            if name.is_empty() {
362                "HEAD".to_owned()
363            } else {
364                name
365            }
366        };
367        let lines = |out: Output| String::from_utf8_lossy(&out.stdout).lines().count();
368        let count = |out: Output| -> Option<usize> {
369            if !out.status.success() {
370                return None;
371            }
372            String::from_utf8_lossy(&out.stdout).trim().parse().ok()
373        };
374        Ok(RepoVitals {
375            branch,
376            changes: lines(changes??),
377            stashes: lines(stashes??),
378            ahead: count(ahead??),
379            level: count(level??).unwrap_or(0),
380        })
381    }
382
383    /// 将解决后的内容写入工作区文件并 `git add`。
384    pub fn stage_resolved(&self, path: &str, content: &[u8]) -> Result<(), GitError> {
385        std::fs::write(self.top.join(path), content)?;
386        self.run_ok(&["add", "--", path])?;
387        Ok(())
388    }
389
390    /// 继续当前 merge / rebase(冲突全部解决后调用)。
391    ///
392    /// 以透传模式执行:git 与钩子的输出(含颜色)实时流向用户终端。
393    /// 返回退出码而非直接判错:rebase --continue 在下一个 commit
394    /// 冲突时也会非零退出,是否算失败由调用方结合冲突探测决定。
395    pub fn continue_op(&self, state: RepoState) -> Result<ExitStatus, GitError> {
396        let op = match state {
397            RepoState::Clean => {
398                return Err(GitError::Failed {
399                    cmd: "--continue".to_owned(),
400                    stderr: crate::i18n::tr("git.no_op").to_owned(),
401                });
402            }
403            other => other.op_name(),
404        };
405        self.run_inherit(&["-c", "core.editor=true", op, "--continue"])
406    }
407
408    /// 中止当前 merge / rebase。
409    pub fn abort_op(&self, state: RepoState) -> Result<(), GitError> {
410        let op = match state {
411            RepoState::Clean => {
412                return Err(GitError::Failed {
413                    cmd: "--abort".to_owned(),
414                    stderr: crate::i18n::tr("git.no_op").to_owned(),
415                });
416            }
417            other => other.op_name(),
418        };
419        self.run_ok(&[op, "--abort"]).map(|_| ())
420    }
421}
422
423/// 解析 `git ls-files -u -z` 的输出。
424///
425/// 每个条目格式为 `<mode> <oid> <stage>\t<path>`,以 NUL 分隔;
426/// 同一路径会按 stage 出现 1~3 次,归组为单个 [`ConflictedFile`]。
427fn parse_ls_files_unmerged(text: &str) -> Vec<ConflictedFile> {
428    let mut files: Vec<ConflictedFile> = Vec::new();
429    for entry in text.split('\0').filter(|e| !e.is_empty()) {
430        let Some((meta, path)) = entry.split_once('\t') else {
431            continue;
432        };
433        let mut fields = meta.split_whitespace().skip(1);
434        let (Some(oid), Some(stage)) = (fields.next(), fields.next()) else {
435            continue;
436        };
437        let idx = match files.iter().position(|f| f.path == path) {
438            Some(i) => i,
439            None => {
440                files.push(ConflictedFile {
441                    path: path.to_owned(),
442                    base: None,
443                    ours: None,
444                    theirs: None,
445                });
446                files.len() - 1
447            }
448        };
449        match stage {
450            "1" => files[idx].base = Some(oid.to_owned()),
451            "2" => files[idx].ours = Some(oid.to_owned()),
452            "3" => files[idx].theirs = Some(oid.to_owned()),
453            _ => {}
454        }
455    }
456    files
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    #[test]
464    fn parses_unmerged_entries_grouped_by_path() {
465        let text = "100644 aaaa 1\tsrc/a.rs\x00100644 bbbb 2\tsrc/a.rs\x00100644 cccc 3\tsrc/a.rs\x00100644 dddd 2\tREADME.md\x00100644 eeee 3\tREADME.md\x00";
466        let files = parse_ls_files_unmerged(text);
467        assert_eq!(files.len(), 2);
468        assert_eq!(
469            files[0],
470            ConflictedFile {
471                path: "src/a.rs".to_owned(),
472                base: Some("aaaa".to_owned()),
473                ours: Some("bbbb".to_owned()),
474                theirs: Some("cccc".to_owned()),
475            }
476        );
477        // add/add 冲突:没有 stage 1
478        assert!(files[1].base.is_none());
479        assert!(files[1].ours.is_some() && files[1].theirs.is_some());
480    }
481
482    #[test]
483    fn parses_empty_output() {
484        assert!(parse_ls_files_unmerged("").is_empty());
485    }
486
487    #[test]
488    fn tolerates_tab_in_path() {
489        // -z 模式下路径不转义,首个 \t 之后整体视为路径
490        let text = "100644 aaaa 2\ta\tb.txt\0";
491        let files = parse_ls_files_unmerged(text);
492        assert_eq!(files[0].path, "a\tb.txt");
493    }
494}