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#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct ConflictedFile {
69    /// 相对仓库根目录的路径
70    pub path: String,
71    /// 是否存在 stage 1(base;add/add 冲突时缺失)
72    pub has_base: bool,
73    /// 是否存在 stage 2(ours;对方删除本方修改时才缺失本方)
74    pub has_ours: bool,
75    /// 是否存在 stage 3(theirs)
76    pub has_theirs: bool,
77}
78
79/// 仓库体征:主菜单 RPG 状态面板展示的数据,打开菜单时查询一次。
80#[derive(Debug, Clone)]
81pub struct RepoVitals {
82    /// 当前分支名(detached HEAD 时为 "HEAD")
83    pub branch: String,
84    /// 工作区 + 暂存区改动条目数
85    pub changes: usize,
86    /// stash 条目数
87    pub stashes: usize,
88    /// 领先上游的提交数;未设置上游分支时为 None
89    pub ahead: Option<usize>,
90    /// HEAD 可达的提交总数(空仓库为 0)
91    pub level: usize,
92}
93
94/// git 调用上下文:锚定仓库根目录,verbose 时回显执行的命令。
95pub struct Git {
96    /// 仓库根目录;所有命令经 `-C` 锚定,路径统一为根相对
97    top: PathBuf,
98    /// 是否回显执行的 git 命令(-v)
99    verbose: bool,
100}
101
102/// 将进程启动错误归一化为 GitError。
103fn spawn_err(e: std::io::Error) -> GitError {
104    if e.kind() == std::io::ErrorKind::NotFound {
105        GitError::NotFound
106    } else {
107        GitError::Io(e)
108    }
109}
110
111/// 需要从子进程环境中清除的 git 变量。
112///
113/// 当本工具从 git 钩子里被间接调起时(如 pre-commit 里跑测试),
114/// 外层 git 会注入这些指向宿主仓库的路径;嵌套 git 调用若继承它们,
115/// 会被劫持到错误的仓库,必须清掉。
116const SCRUBBED_GIT_ENV: [&str; 4] = [
117    "GIT_DIR",
118    "GIT_WORK_TREE",
119    "GIT_INDEX_FILE",
120    "GIT_OBJECT_DIRECTORY",
121];
122
123/// 构造锚定到指定目录的基础 git 命令:清理宿主 git 环境 + 禁用交互编辑器。
124fn base_git(dir: &Path) -> Command {
125    let mut cmd = Command::new("git");
126    cmd.arg("-C").arg(dir).env("GIT_EDITOR", "true");
127    for var in SCRUBBED_GIT_ENV {
128        cmd.env_remove(var);
129    }
130    cmd
131}
132
133impl Git {
134    /// 从指定目录探测 git 仓库根并构造上下文。
135    pub fn discover(dir: &Path, verbose: bool) -> Result<Self, GitError> {
136        let out = base_git(dir)
137            .args(["rev-parse", "--show-toplevel"])
138            .output()
139            .map_err(spawn_err)?;
140        if !out.status.success() {
141            return Err(GitError::NotARepo);
142        }
143        let top = String::from_utf8_lossy(&out.stdout).trim().to_owned();
144        Ok(Self {
145            top: PathBuf::from(top),
146            verbose,
147        })
148    }
149
150    /// 仓库根目录。
151    pub fn top(&self) -> &Path {
152        &self.top
153    }
154
155    /// 执行 git 命令并捕获输出(不检查退出码,交给调用方判断)。
156    pub fn run(&self, args: &[&str]) -> Result<Output, GitError> {
157        if self.verbose {
158            eprintln!("[git] git {}", args.join(" "));
159        }
160        base_git(&self.top).args(args).output().map_err(spawn_err)
161    }
162
163    /// 执行 git 命令,非零退出码视为错误。
164    pub fn run_ok(&self, args: &[&str]) -> Result<Output, GitError> {
165        let out = self.run(args)?;
166        if !out.status.success() {
167            return Err(GitError::Failed {
168                cmd: args.join(" "),
169                stderr: String::from_utf8_lossy(&out.stderr).trim().to_owned(),
170            });
171        }
172        Ok(out)
173    }
174
175    /// 以透传模式执行 git:输出直接接到用户终端,
176    /// 用于发起 merge / rebase / pull(保留 git 自身的进度显示)。
177    pub fn run_inherit(&self, args: &[&str]) -> Result<ExitStatus, GitError> {
178        if self.verbose {
179            eprintln!("[git] git {}", args.join(" "));
180        }
181        base_git(&self.top).args(args).status().map_err(spawn_err)
182    }
183
184    /// 探测仓库当前的合并状态。
185    pub fn state(&self) -> Result<RepoState, GitError> {
186        let out = self.run_ok(&["rev-parse", "--git-dir"])?;
187        let raw = String::from_utf8_lossy(&out.stdout).trim().to_owned();
188        // --git-dir 可能返回相对路径(相对仓库根)
189        let git_dir = {
190            let p = PathBuf::from(&raw);
191            if p.is_absolute() { p } else { self.top.join(p) }
192        };
193        // rebase 必须先于 cherry-pick 判定:交互式 rebase 内部逐个重放提交,
194        // 冲突时也会留下 CHERRY_PICK_HEAD,但收尾命令是 rebase --continue
195        if git_dir.join("rebase-apply").exists() {
196            // rebase-apply/applying 是 git am 的标记(git 自身也以此区分两者)
197            if git_dir.join("rebase-apply/applying").exists() {
198                Ok(RepoState::Am)
199            } else {
200                Ok(RepoState::Rebasing)
201            }
202        } else if git_dir.join("rebase-merge").exists() {
203            Ok(RepoState::Rebasing)
204        } else if git_dir.join("CHERRY_PICK_HEAD").exists() {
205            Ok(RepoState::CherryPicking)
206        } else if git_dir.join("REVERT_HEAD").exists() {
207            Ok(RepoState::Reverting)
208        } else if git_dir.join("MERGE_HEAD").exists() {
209            Ok(RepoState::Merging)
210        } else {
211            Ok(RepoState::Clean)
212        }
213    }
214
215    /// 列出所有处于冲突状态的文件。
216    pub fn conflicted_files(&self) -> Result<Vec<ConflictedFile>, GitError> {
217        let out = self.run_ok(&["ls-files", "-u", "-z"])?;
218        Ok(parse_ls_files_unmerged(&String::from_utf8_lossy(
219            &out.stdout,
220        )))
221    }
222
223    /// 读取冲突文件某个 stage 的完整内容(1=base,2=ours,3=theirs)。
224    pub fn read_stage(&self, path: &str, stage: u8) -> Result<Vec<u8>, GitError> {
225        let spec = format!(":{stage}:{path}");
226        Ok(self.run_ok(&["show", &spec])?.stdout)
227    }
228
229    /// 列出可作为 merge / rebase 目标的分支:本地 + 远程跟踪,
230    /// 排除当前分支与 HEAD 符号引用。
231    pub fn list_branches(&self) -> Result<Vec<String>, GitError> {
232        let current = {
233            let out = self.run_ok(&["branch", "--show-current"])?;
234            String::from_utf8_lossy(&out.stdout).trim().to_owned()
235        };
236        let queries: [&[&str]; 2] = [
237            &["branch", "--format=%(refname:short)"],
238            &["branch", "-r", "--format=%(refname:short)"],
239        ];
240        let mut branches = Vec::new();
241        for args in queries {
242            let out = self.run_ok(args)?;
243            for line in String::from_utf8_lossy(&out.stdout).lines() {
244                let name = line.trim();
245                if name.is_empty() || name == current || name.contains("HEAD") {
246                    continue;
247                }
248                branches.push(name.to_owned());
249            }
250        }
251        Ok(branches)
252    }
253
254    /// 最近提交列表(`--oneline` 行,首列为短 hash),提交选择器用。
255    ///
256    /// `others_only` 为 true 时只列不在当前分支上的提交(cherry-pick 候选),
257    /// 否则列当前分支的最近提交(revert 候选)。
258    /// 空仓库等无提交可列的场景返回空列表而非报错。
259    pub fn recent_commits(&self, others_only: bool, limit: usize) -> Result<Vec<String>, GitError> {
260        let n = format!("-n{limit}");
261        let mut args = vec!["log", "--oneline", &n];
262        if others_only {
263            args.extend(["--all", "--not", "HEAD"]);
264        }
265        let out = self.run(&args)?;
266        if !out.status.success() {
267            return Ok(Vec::new());
268        }
269        Ok(String::from_utf8_lossy(&out.stdout)
270            .lines()
271            .map(str::to_owned)
272            .collect())
273    }
274
275    /// 探测仓库体征:分支、改动数、贮藏数、待推送数与提交总数。
276    ///
277    /// 均为廉价的本地查询;`ahead` / `level` 在无上游 / 空仓库时
278    /// 查询会非零退出,分别归一化为 None / 0 而非报错。
279    pub fn vitals(&self) -> Result<RepoVitals, GitError> {
280        let branch = {
281            let out = self.run_ok(&["branch", "--show-current"])?;
282            let name = String::from_utf8_lossy(&out.stdout).trim().to_owned();
283            if name.is_empty() {
284                "HEAD".to_owned()
285            } else {
286                name
287            }
288        };
289        let changes = {
290            let out = self.run_ok(&["status", "--porcelain"])?;
291            String::from_utf8_lossy(&out.stdout).lines().count()
292        };
293        let stashes = {
294            let out = self.run_ok(&["stash", "list"])?;
295            String::from_utf8_lossy(&out.stdout).lines().count()
296        };
297        let count = |args: &[&str]| -> Result<Option<usize>, GitError> {
298            let out = self.run(args)?;
299            if !out.status.success() {
300                return Ok(None);
301            }
302            Ok(String::from_utf8_lossy(&out.stdout).trim().parse().ok())
303        };
304        let ahead = count(&["rev-list", "--count", "@{upstream}..HEAD"])?;
305        let level = count(&["rev-list", "--count", "HEAD"])?.unwrap_or(0);
306        Ok(RepoVitals {
307            branch,
308            changes,
309            stashes,
310            ahead,
311            level,
312        })
313    }
314
315    /// 将解决后的内容写入工作区文件并 `git add`。
316    pub fn stage_resolved(&self, path: &str, content: &[u8]) -> Result<(), GitError> {
317        std::fs::write(self.top.join(path), content)?;
318        self.run_ok(&["add", "--", path])?;
319        Ok(())
320    }
321
322    /// 继续当前 merge / rebase(冲突全部解决后调用)。
323    ///
324    /// 以透传模式执行:git 与钩子的输出(含颜色)实时流向用户终端。
325    /// 返回退出码而非直接判错:rebase --continue 在下一个 commit
326    /// 冲突时也会非零退出,是否算失败由调用方结合冲突探测决定。
327    pub fn continue_op(&self, state: RepoState) -> Result<ExitStatus, GitError> {
328        let op = match state {
329            RepoState::Clean => {
330                return Err(GitError::Failed {
331                    cmd: "--continue".to_owned(),
332                    stderr: crate::i18n::tr("git.no_op").to_owned(),
333                });
334            }
335            other => other.op_name(),
336        };
337        self.run_inherit(&["-c", "core.editor=true", op, "--continue"])
338    }
339
340    /// 中止当前 merge / rebase。
341    pub fn abort_op(&self, state: RepoState) -> Result<(), GitError> {
342        let op = match state {
343            RepoState::Clean => {
344                return Err(GitError::Failed {
345                    cmd: "--abort".to_owned(),
346                    stderr: crate::i18n::tr("git.no_op").to_owned(),
347                });
348            }
349            other => other.op_name(),
350        };
351        self.run_ok(&[op, "--abort"]).map(|_| ())
352    }
353}
354
355/// 解析 `git ls-files -u -z` 的输出。
356///
357/// 每个条目格式为 `<mode> <oid> <stage>\t<path>`,以 NUL 分隔;
358/// 同一路径会按 stage 出现 1~3 次,归组为单个 [`ConflictedFile`]。
359fn parse_ls_files_unmerged(text: &str) -> Vec<ConflictedFile> {
360    let mut files: Vec<ConflictedFile> = Vec::new();
361    for entry in text.split('\0').filter(|e| !e.is_empty()) {
362        let Some((meta, path)) = entry.split_once('\t') else {
363            continue;
364        };
365        let stage = meta.split_whitespace().nth(2).unwrap_or("0");
366        let idx = match files.iter().position(|f| f.path == path) {
367            Some(i) => i,
368            None => {
369                files.push(ConflictedFile {
370                    path: path.to_owned(),
371                    has_base: false,
372                    has_ours: false,
373                    has_theirs: false,
374                });
375                files.len() - 1
376            }
377        };
378        match stage {
379            "1" => files[idx].has_base = true,
380            "2" => files[idx].has_ours = true,
381            "3" => files[idx].has_theirs = true,
382            _ => {}
383        }
384    }
385    files
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn parses_unmerged_entries_grouped_by_path() {
394        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";
395        let files = parse_ls_files_unmerged(text);
396        assert_eq!(files.len(), 2);
397        assert_eq!(
398            files[0],
399            ConflictedFile {
400                path: "src/a.rs".to_owned(),
401                has_base: true,
402                has_ours: true,
403                has_theirs: true,
404            }
405        );
406        // add/add 冲突:没有 stage 1
407        assert!(!files[1].has_base);
408        assert!(files[1].has_ours && files[1].has_theirs);
409    }
410
411    #[test]
412    fn parses_empty_output() {
413        assert!(parse_ls_files_unmerged("").is_empty());
414    }
415
416    #[test]
417    fn tolerates_tab_in_path() {
418        // -z 模式下路径不转义,首个 \t 之后整体视为路径
419        let text = "100644 aaaa 2\ta\tb.txt\0";
420        let files = parse_ls_files_unmerged(text);
421        assert_eq!(files[0].path, "a\tb.txt");
422    }
423}