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/// The worktrees Git lists for `common_dir` that exist now: one Git marks prunable (its folder
66/// was removed) or whose folder is gone is not a worktree any more. NUL-delimited porcelain does
67/// not quote spaces, tabs or newlines in paths.
68pub fn worktrees(common_dir: &Path) -> Vec<Worktree> {
69    let args = [OsStr::new("--git-dir"), common_dir.as_os_str()]
70        .into_iter()
71        .chain(["worktree", "list", "--porcelain", "-z"].map(OsStr::new));
72    crate::process::tool_output("git", args)
73        .map(|output| parse_worktrees(&output))
74        .unwrap_or_default()
75        .into_iter()
76        .filter(|tree| tree.path.try_exists().unwrap_or(false))
77        .collect()
78}
79
80fn parse_worktrees(bytes: &[u8]) -> Vec<Worktree> {
81    let mut trees: Vec<(Worktree, bool)> = Vec::new();
82    for field in bytes.split(|byte| *byte == 0) {
83        if let Some(path) = field.strip_prefix(b"worktree ") {
84            let path = crate::platform::path_from_bytes(path);
85            trees.push((Worktree { path, branch: None }, false));
86        } else if let Some((tree, _)) = trees.last_mut()
87            && let Some(branch) = field.strip_prefix(b"branch refs/heads/")
88        {
89            tree.branch = Some(String::from_utf8_lossy(branch).into_owned());
90        } else if let Some((_, prunable)) = trees.last_mut()
91            && (field == b"prunable" || field.starts_with(b"prunable "))
92        {
93            *prunable = true;
94        }
95    }
96    trees
97        .into_iter()
98        .filter_map(|(tree, prunable)| (!prunable).then_some(tree))
99        .collect()
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn git_identity_preserves_supported_path_characters() {
108        let root = crate::test_os::tempdir();
109        for path in crate::test_os::git_path_cases(root.path()) {
110            crate::test_os::git(&path, &["init", "--quiet"]);
111            let identity = identify(&path);
112            assert_eq!(
113                identity.common_dir,
114                Some(crate::test_os::canonical(&path.join(".git"))),
115                "{path:?}"
116            );
117            assert_eq!(
118                identity.worktree_path,
119                Some(crate::test_os::canonical(&path)),
120                "{path:?}"
121            );
122        }
123    }
124
125    #[test]
126    fn porcelain_paths_are_not_split_on_whitespace() {
127        let root = crate::test_os::tempdir();
128        // Parser input only: no directory with an embedded newline is created.
129        let named = root.path().join("a space\nline");
130        let detached = root.path().join("detached");
131        let wire = format!(
132            "worktree {}\0HEAD abc\0branch refs/heads/main\0\0worktree {}\0HEAD def\0detached\0\0",
133            crate::platform::path_text(&named),
134            crate::platform::path_text(&detached),
135        );
136        let trees = parse_worktrees(wire.as_bytes());
137        assert_eq!(trees[0].path, named);
138        assert_eq!(trees[0].branch.as_deref(), Some("main"));
139        assert_eq!(trees[1].path, detached);
140        assert_eq!(trees[1].branch, None);
141    }
142
143    #[test]
144    fn prunable_worktrees_are_not_listed() {
145        let wire = "worktree /work/main\0HEAD abc\0branch refs/heads/main\0\0worktree /work/gone\0HEAD def\0branch refs/heads/gone\0prunable gitdir file points to non-existent location\0\0";
146        let trees = parse_worktrees(wire.as_bytes());
147        assert_eq!(trees.len(), 1);
148        assert_eq!(trees[0].branch.as_deref(), Some("main"));
149    }
150
151    #[test]
152    fn a_removed_worktree_folder_is_not_listed() {
153        let root = crate::test_os::tempdir();
154        let main = root.path().join("main");
155        std::fs::create_dir(&main).unwrap();
156        crate::test_os::git(&main, &["init", "--quiet", "--initial-branch=main"]);
157        crate::test_os::git(
158            &main,
159            &[
160                "-c",
161                "user.name=artifactize",
162                "-c",
163                "user.email=artifactize@example.invalid",
164                "commit",
165                "--quiet",
166                "--allow-empty",
167                "-m",
168                "first",
169            ],
170        );
171        let other = root.path().join("other");
172        crate::test_os::git(
173            &main,
174            &[
175                "worktree",
176                "add",
177                "--quiet",
178                "-b",
179                "other",
180                &crate::platform::path_text(&other),
181            ],
182        );
183        let common = crate::test_os::canonical(&main.join(".git"));
184        let branches = |trees: Vec<Worktree>| -> Vec<Option<String>> {
185            trees.into_iter().map(|tree| tree.branch).collect()
186        };
187        assert_eq!(
188            branches(worktrees(&common)),
189            [Some("main".into()), Some("other".into())]
190        );
191        std::fs::remove_dir_all(&other).unwrap();
192        assert_eq!(branches(worktrees(&common)), [Some("main".into())]);
193    }
194}