Skip to main content

artifactize_core/
repository.rs

1//! Git display identity, separate from the artifactize workspace used for execution.
2
3use serde::{Deserialize, Serialize};
4use std::{
5    ffi::OsStr,
6    path::{Path, PathBuf},
7};
8
9#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "camelCase")]
11pub struct Identity {
12    #[serde(
13        default,
14        skip_serializing_if = "Option::is_none",
15        with = "crate::platform::path_serde::option"
16    )]
17    pub common_dir: Option<PathBuf>,
18    #[serde(
19        default,
20        skip_serializing_if = "Option::is_none",
21        with = "crate::platform::path_serde::option"
22    )]
23    pub worktree_path: Option<PathBuf>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub branch: Option<String>,
26}
27
28fn git(path: &Path, args: &[&str]) -> Option<Vec<u8>> {
29    let args = [OsStr::new("-C"), path.as_os_str()]
30        .into_iter()
31        .chain(args.iter().map(OsStr::new));
32    crate::process::tool_output("git", args)
33}
34
35fn git_path(path: &Path, arg: &str) -> Option<PathBuf> {
36    let output = git(path, &["rev-parse", "--path-format=absolute", arg])?;
37    // Only the native output terminator is removed; path bytes remain the platform's own.
38    let path = crate::platform::path_from_line(&output);
39    crate::platform::canonicalize(&path).ok()
40}
41
42/// Missing paths and non-Git workspaces have no inferred Git identity.
43pub fn identify(path: &Path) -> Identity {
44    let common_dir = git_path(path, "--git-common-dir");
45    let worktree_path = git_path(path, "--show-toplevel");
46    if common_dir.is_none() || worktree_path.is_none() {
47        return Identity::default();
48    }
49    let branch = git(path, &["symbolic-ref", "--quiet", "--short", "HEAD"])
50        .and_then(|bytes| String::from_utf8(bytes).ok())
51        .map(|text| text.trim_end_matches(['\r', '\n']).to_owned());
52    Identity {
53        common_dir,
54        worktree_path,
55        branch,
56    }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct Worktree {
61    pub path: PathBuf,
62    pub branch: Option<String>,
63}
64
65/// NUL-delimited porcelain does not quote spaces, tabs or newlines in paths.
66pub fn worktrees(common_dir: &Path) -> Vec<Worktree> {
67    let args = [OsStr::new("--git-dir"), common_dir.as_os_str()]
68        .into_iter()
69        .chain(["worktree", "list", "--porcelain", "-z"].map(OsStr::new));
70    crate::process::tool_output("git", args)
71        .map(|output| parse_worktrees(&output))
72        .unwrap_or_default()
73}
74
75fn parse_worktrees(bytes: &[u8]) -> Vec<Worktree> {
76    let mut trees: Vec<Worktree> = Vec::new();
77    for field in bytes.split(|byte| *byte == 0) {
78        if let Some(path) = field.strip_prefix(b"worktree ") {
79            let path = crate::platform::path_from_bytes(path);
80            trees.push(Worktree { path, branch: None });
81        } else if let Some(branch) = field.strip_prefix(b"branch refs/heads/")
82            && let Some(tree) = trees.last_mut()
83        {
84            tree.branch = Some(String::from_utf8_lossy(branch).into_owned());
85        }
86    }
87    trees
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn git_identity_preserves_supported_path_characters() {
96        let root = crate::test_os::tempdir();
97        for path in crate::test_os::git_path_cases(root.path()) {
98            crate::test_os::git(&path, &["init", "--quiet"]);
99            let identity = identify(&path);
100            assert_eq!(
101                identity.common_dir,
102                Some(crate::test_os::canonical(&path.join(".git"))),
103                "{path:?}"
104            );
105            assert_eq!(
106                identity.worktree_path,
107                Some(crate::test_os::canonical(&path)),
108                "{path:?}"
109            );
110        }
111    }
112
113    #[test]
114    fn porcelain_paths_are_not_split_on_whitespace() {
115        let root = crate::test_os::tempdir();
116        // Parser input only: no directory with an embedded newline is created.
117        let named = root.path().join("a space\nline");
118        let detached = root.path().join("detached");
119        let wire = format!(
120            "worktree {}\0HEAD abc\0branch refs/heads/main\0\0worktree {}\0HEAD def\0detached\0\0",
121            crate::platform::path_text(&named),
122            crate::platform::path_text(&detached),
123        );
124        let trees = parse_worktrees(wire.as_bytes());
125        assert_eq!(trees[0].path, named);
126        assert_eq!(trees[0].branch.as_deref(), Some("main"));
127        assert_eq!(trees[1].path, detached);
128        assert_eq!(trees[1].branch, None);
129    }
130}