Skip to main content

steer_workspace/utils/
vcs.rs

1use std::path::Path;
2
3/// Common git status functionality for workspaces
4pub struct GitStatusUtils;
5
6impl GitStatusUtils {
7    /// Get git status information for a repository
8    pub fn get_git_status(repo_path: &Path) -> Result<crate::GitStatus, std::io::Error> {
9        let repo = gix::discover(repo_path)
10            .map_err(|e| std::io::Error::other(format!("Failed to open git repository: {e}")))?;
11
12        // Get current branch
13        let head = match repo.head_name() {
14            Ok(Some(name)) => {
15                let branch = name.as_bstr().to_string();
16                let branch = branch.strip_prefix("refs/heads/").unwrap_or(&branch);
17                crate::GitHead::Branch(branch.to_string())
18            }
19            Ok(None) => crate::GitHead::Detached,
20            Err(e) => {
21                if e.to_string().contains("does not exist") {
22                    crate::GitHead::Unborn
23                } else {
24                    return Err(std::io::Error::other(format!("Failed to get HEAD: {e}")));
25                }
26            }
27        };
28
29        // Get status
30        let iter = repo
31            .status(gix::progress::Discard)
32            .map_err(|e| std::io::Error::other(format!("Failed to get git status: {e}")))?
33            .into_index_worktree_iter(Vec::new())
34            .map_err(|e| std::io::Error::other(format!("Failed to get git status: {e}")))?;
35        use gix::bstr::ByteSlice;
36        use gix::status::index_worktree::iter::Summary;
37        let mut entries = Vec::new();
38        for item_res in iter {
39            let item = item_res
40                .map_err(|e| std::io::Error::other(format!("Failed to get git status: {e}")))?;
41            if let Some(summary) = item.summary() {
42                let path = item.rela_path().to_str_lossy();
43                let summary = match summary {
44                    Summary::Added => crate::GitStatusSummary::Added,
45                    Summary::Removed => crate::GitStatusSummary::Removed,
46                    Summary::Modified => crate::GitStatusSummary::Modified,
47                    Summary::TypeChange => crate::GitStatusSummary::TypeChange,
48                    Summary::Renamed => crate::GitStatusSummary::Renamed,
49                    Summary::Copied => crate::GitStatusSummary::Copied,
50                    Summary::IntentToAdd => crate::GitStatusSummary::IntentToAdd,
51                    Summary::Conflict => crate::GitStatusSummary::Conflict,
52                };
53                entries.push(crate::GitStatusEntry {
54                    summary,
55                    path: path.to_string(),
56                });
57            }
58        }
59
60        // Get recent commits
61        let mut recent_commits = Vec::new();
62        if let Ok(head_id) = repo.head_id() {
63            let oid = head_id.detach();
64            if let Ok(object) = repo.find_object(oid)
65                && let Ok(commit) = object.try_into_commit()
66            {
67                // Just show the HEAD commit for now, as rev_walk API changed
68                let summary_bytes = commit.message_raw_sloppy();
69                let summary = summary_bytes
70                    .lines()
71                    .next()
72                    .and_then(|line| std::str::from_utf8(line).ok())
73                    .unwrap_or("<no summary>");
74                let short_id = oid.to_hex().to_string();
75                let short_id = &short_id[..7.min(short_id.len())];
76                recent_commits.push(crate::GitCommitSummary {
77                    id: short_id.to_string(),
78                    summary: summary.to_string(),
79                });
80            }
81        }
82
83        Ok(crate::GitStatus::new(head, entries, recent_commits))
84    }
85}
86
87trait VcsProvider {
88    fn kind(&self) -> crate::VcsKind;
89    fn root(&self) -> &Path;
90    fn status(&self) -> Result<crate::VcsStatus, std::io::Error>;
91}
92
93struct GitProvider {
94    root: std::path::PathBuf,
95}
96
97impl VcsProvider for GitProvider {
98    fn kind(&self) -> crate::VcsKind {
99        crate::VcsKind::Git
100    }
101
102    fn root(&self) -> &Path {
103        &self.root
104    }
105
106    fn status(&self) -> Result<crate::VcsStatus, std::io::Error> {
107        GitStatusUtils::get_git_status(&self.root).map(crate::VcsStatus::Git)
108    }
109}
110
111struct JjProvider {
112    root: std::path::PathBuf,
113}
114
115impl VcsProvider for JjProvider {
116    fn kind(&self) -> crate::VcsKind {
117        crate::VcsKind::Jj
118    }
119
120    fn root(&self) -> &Path {
121        &self.root
122    }
123
124    fn status(&self) -> Result<crate::VcsStatus, std::io::Error> {
125        JjStatusUtils::get_jj_status(&self.root).map(crate::VcsStatus::Jj)
126    }
127}
128
129/// Common VCS detection and status functionality
130pub struct VcsUtils;
131
132impl VcsUtils {
133    pub fn collect_vcs_info(path: &Path) -> Option<crate::VcsInfo> {
134        let provider = Self::detect_provider(path)?;
135        let status = match provider.status() {
136            Ok(status) => status,
137            Err(err) => match provider.kind() {
138                crate::VcsKind::Git => {
139                    crate::VcsStatus::Git(crate::GitStatus::unavailable(err.to_string()))
140                }
141                crate::VcsKind::Jj => {
142                    crate::VcsStatus::Jj(crate::JjStatus::unavailable(err.to_string()))
143                }
144            },
145        };
146        Some(crate::VcsInfo {
147            kind: provider.kind(),
148            root: provider.root().to_path_buf(),
149            status,
150        })
151    }
152
153    fn detect_provider(path: &Path) -> Option<Box<dyn VcsProvider>> {
154        let jj_root = Self::find_marker_root(path, ".jj");
155        let git_root = Self::find_git_root(path);
156
157        match (jj_root, git_root) {
158            (Some(jj_root), Some(git_root)) => {
159                let jj_distance = Self::distance_from(path, &jj_root);
160                let git_distance = Self::distance_from(path, &git_root);
161
162                match (jj_distance, git_distance) {
163                    (Some(jj_distance), Some(git_distance)) => {
164                        match jj_distance.cmp(&git_distance) {
165                            std::cmp::Ordering::Less | std::cmp::Ordering::Equal => {
166                                Some(Box::new(JjProvider { root: jj_root }))
167                            }
168                            std::cmp::Ordering::Greater => {
169                                Some(Box::new(GitProvider { root: git_root }))
170                            }
171                        }
172                    }
173                    (Some(_), None) => Some(Box::new(JjProvider { root: jj_root })),
174                    (None, Some(_)) => Some(Box::new(GitProvider { root: git_root })),
175                    (None, None) => Some(Box::new(JjProvider { root: jj_root })),
176                }
177            }
178            (Some(jj_root), None) => Some(Box::new(JjProvider { root: jj_root })),
179            (None, Some(git_root)) => Some(Box::new(GitProvider { root: git_root })),
180            (None, None) => None,
181        }
182    }
183
184    fn find_marker_root(path: &Path, marker: &str) -> Option<std::path::PathBuf> {
185        let mut current = Some(path);
186        while let Some(dir) = current {
187            if dir.join(marker).is_dir() {
188                return Some(dir.to_path_buf());
189            }
190            current = dir.parent();
191        }
192        None
193    }
194
195    fn find_git_root(path: &Path) -> Option<std::path::PathBuf> {
196        let repo = gix::discover(path).ok()?;
197        let root = repo.workdir().unwrap_or_else(|| repo.path());
198        Some(root.to_path_buf())
199    }
200
201    fn distance_from(path: &Path, root: &Path) -> Option<usize> {
202        let relative = path.strip_prefix(root).ok()?;
203        Some(relative.components().count())
204    }
205
206    #[cfg(test)]
207    fn detect_provider_for_tests(path: &Path) -> Option<Box<dyn VcsProvider>> {
208        Self::detect_provider(path)
209    }
210}
211
212struct JjStatusUtils;
213
214impl JjStatusUtils {
215    pub fn get_jj_status(workspace_root: &Path) -> Result<crate::JjStatus, std::io::Error> {
216        use jj_lib::config::{ConfigSource, StackedConfig};
217        use jj_lib::matchers::EverythingMatcher;
218        use jj_lib::object_id::ObjectId;
219        use jj_lib::repo::{Repo, StoreFactories};
220        use jj_lib::settings::UserSettings;
221        use jj_lib::workspace::{Workspace, default_working_copy_factories};
222
223        let mut config = StackedConfig::with_defaults();
224        let jj_dir = workspace_root.join(".jj");
225        let repo_config = jj_dir.join("repo").join("config.toml");
226        if repo_config.is_file() {
227            config
228                .load_file(ConfigSource::Repo, repo_config)
229                .map_err(|e| {
230                    std::io::Error::other(format!("Failed to load jj repo config: {e}"))
231                })?;
232        }
233        let workspace_config = jj_dir.join("workspace-config.toml");
234        if workspace_config.is_file() {
235            config
236                .load_file(ConfigSource::Workspace, workspace_config)
237                .map_err(|e| {
238                    std::io::Error::other(format!("Failed to load jj workspace config: {e}"))
239                })?;
240        }
241
242        let settings = UserSettings::from_config(config)
243            .map_err(|e| std::io::Error::other(format!("Failed to build jj settings: {e}")))?;
244        let store_factories = StoreFactories::default();
245        let working_copy_factories = default_working_copy_factories();
246        let workspace = Workspace::load(
247            &settings,
248            workspace_root,
249            &store_factories,
250            &working_copy_factories,
251        )
252        .map_err(|e| std::io::Error::other(format!("Failed to load jj workspace: {e}")))?;
253        let repo = workspace
254            .repo_loader()
255            .load_at_head()
256            .map_err(|e| std::io::Error::other(format!("Failed to load jj repo: {e}")))?;
257
258        let workspace_name = workspace.workspace_name();
259        let wc_commit_id = repo
260            .view()
261            .get_wc_commit_id(workspace_name)
262            .ok_or_else(|| {
263                std::io::Error::other(format!(
264                    "No working copy commit for workspace '{}'",
265                    workspace_name.as_str()
266                ))
267            })?;
268        let wc_commit = repo.store().get_commit(wc_commit_id).map_err(|e| {
269            std::io::Error::other(format!("Failed to load working copy commit: {e}"))
270        })?;
271
272        let parent_tree = wc_commit
273            .parent_tree(repo.as_ref())
274            .map_err(|e| std::io::Error::other(format!("Failed to load parent tree: {e}")))?;
275        let wc_tree = wc_commit
276            .tree()
277            .map_err(|e| std::io::Error::other(format!("Failed to load working copy tree: {e}")))?;
278        let changes = Self::collect_changes(&parent_tree, &wc_tree, &EverythingMatcher)?;
279
280        let change_id_full = wc_commit.change_id().reverse_hex();
281        let change_id = short_id(&change_id_full).to_string();
282        let commit_id_full = wc_commit.id().hex();
283        let commit_id = short_id(&commit_id_full).to_string();
284        let description = first_line(wc_commit.description()).trim();
285        let description = if description.is_empty() {
286            "(no description set)".to_string()
287        } else {
288            description.to_string()
289        };
290
291        let working_copy = crate::JjCommitSummary {
292            change_id,
293            commit_id,
294            description,
295        };
296
297        let mut parents = Vec::new();
298        let parent_ids = wc_commit.parent_ids();
299        for parent_id in parent_ids {
300            let parent_commit = repo
301                .store()
302                .get_commit(parent_id)
303                .map_err(|e| std::io::Error::other(format!("Failed to load parent commit: {e}")))?;
304            let parent_change_id_full = parent_commit.change_id().reverse_hex();
305            let parent_change_id = short_id(&parent_change_id_full).to_string();
306            let parent_commit_id_full = parent_commit.id().hex();
307            let parent_commit_id = short_id(&parent_commit_id_full).to_string();
308            let parent_description = first_line(parent_commit.description()).trim();
309            let parent_description = if parent_description.is_empty() {
310                "(no description set)".to_string()
311            } else {
312                parent_description.to_string()
313            };
314            parents.push(crate::JjCommitSummary {
315                change_id: parent_change_id,
316                commit_id: parent_commit_id,
317                description: parent_description,
318            });
319        }
320
321        Ok(crate::JjStatus::new(changes, working_copy, parents))
322    }
323
324    fn collect_changes(
325        parent_tree: &jj_lib::merged_tree::MergedTree,
326        wc_tree: &jj_lib::merged_tree::MergedTree,
327        matcher: &jj_lib::matchers::EverythingMatcher,
328    ) -> Result<Vec<crate::JjChange>, std::io::Error> {
329        let mut changes = Vec::new();
330        for entry in jj_lib::merged_tree::TreeDiffIterator::new(
331            parent_tree.as_merge(),
332            wc_tree.as_merge(),
333            matcher,
334        ) {
335            let diff = entry.values.map_err(|e| {
336                std::io::Error::other(format!("Failed to diff working copy changes: {e}"))
337            })?;
338            if !diff.is_changed() {
339                continue;
340            }
341            let change_type = if diff.before.is_absent() && diff.after.is_present() {
342                crate::JjChangeType::Added
343            } else if diff.before.is_present() && diff.after.is_absent() {
344                crate::JjChangeType::Removed
345            } else {
346                crate::JjChangeType::Modified
347            };
348            changes.push(crate::JjChange {
349                change_type,
350                path: entry.path.as_internal_file_string().to_string(),
351            });
352        }
353        changes.sort_by(|left, right| left.path.cmp(&right.path));
354        Ok(changes)
355    }
356}
357
358fn first_line(text: &str) -> &str {
359    text.lines().next().unwrap_or("")
360}
361
362fn short_id(hex: &str) -> &str {
363    let len = hex.len().min(8);
364    &hex[..len]
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use crate::LlmStatus;
371    use std::sync::Arc;
372    use tempfile::tempdir;
373
374    #[test]
375    fn test_vcs_detection_prefers_jj() {
376        let temp_dir = tempdir().unwrap();
377        std::fs::create_dir(temp_dir.path().join(".git")).unwrap();
378        std::fs::create_dir(temp_dir.path().join(".jj")).unwrap();
379
380        let provider = VcsUtils::detect_provider_for_tests(temp_dir.path()).unwrap();
381        assert!(matches!(provider.kind(), crate::VcsKind::Jj));
382    }
383
384    #[test]
385    fn test_vcs_detection_prefers_closer_git() {
386        let (temp_dir, _workspace, _repo) = init_jj_workspace();
387        let git_dir = temp_dir.path().join("nested");
388        std::fs::create_dir(&git_dir).unwrap();
389        gix::init(&git_dir).unwrap();
390
391        let provider = VcsUtils::detect_provider_for_tests(&git_dir).unwrap();
392        assert!(matches!(provider.kind(), crate::VcsKind::Git));
393    }
394
395    fn jj_settings() -> jj_lib::settings::UserSettings {
396        let mut config = jj_lib::config::StackedConfig::with_defaults();
397        let overrides = jj_lib::config::ConfigLayer::parse(
398            jj_lib::config::ConfigSource::CommandArg,
399            r#"
400user.name = "Test User"
401user.email = "test@example.com"
402operation.hostname = "test-host"
403operation.username = "test-user"
404signing.behavior = "drop"
405debug.randomness-seed = 0
406debug.commit-timestamp = "2001-01-01T00:00:00Z"
407debug.operation-timestamp = "2001-01-01T00:00:00Z"
408"#,
409        )
410        .unwrap();
411        config.add_layer(overrides);
412        jj_lib::settings::UserSettings::from_config(config).unwrap()
413    }
414
415    fn init_jj_workspace() -> (
416        tempfile::TempDir,
417        jj_lib::workspace::Workspace,
418        Arc<jj_lib::repo::ReadonlyRepo>,
419    ) {
420        let temp_dir = tempdir().unwrap();
421        let settings = jj_settings();
422        let (workspace, repo) =
423            jj_lib::workspace::Workspace::init_simple(&settings, temp_dir.path()).unwrap();
424        (temp_dir, workspace, repo)
425    }
426
427    fn create_dirty_working_copy(repo: &Arc<jj_lib::repo::ReadonlyRepo>) {
428        use jj_lib::backend::{CopyId, TreeValue};
429        use jj_lib::merge::Merge;
430        use jj_lib::merged_tree::MergedTreeBuilder;
431        use jj_lib::ref_name::WorkspaceName;
432        use jj_lib::repo::Repo;
433        use jj_lib::repo_path::RepoPathBuf;
434        use std::io::Cursor;
435
436        let workspace_name = WorkspaceName::DEFAULT;
437        let wc_commit_id = repo.view().get_wc_commit_id(workspace_name).unwrap();
438        let wc_commit = repo.store().get_commit(wc_commit_id).unwrap();
439
440        let file_path = RepoPathBuf::from_internal_string("file.txt").unwrap();
441        let mut contents = Cursor::new(b"content".to_vec());
442        let runtime = tokio::runtime::Runtime::new().unwrap();
443        let file_id = runtime
444            .block_on(repo.store().write_file(file_path.as_ref(), &mut contents))
445            .unwrap();
446
447        let mut tree_builder = MergedTreeBuilder::new(repo.store().empty_merged_tree_id());
448        tree_builder.set_or_remove(
449            file_path,
450            Merge::normal(TreeValue::File {
451                id: file_id,
452                executable: false,
453                copy_id: CopyId::new(vec![]),
454            }),
455        );
456        let new_tree_id = tree_builder.write_tree(repo.store()).unwrap();
457
458        let mut tx = repo.start_transaction();
459        let new_commit = tx
460            .repo_mut()
461            .new_commit(wc_commit.parent_ids().to_vec(), new_tree_id)
462            .write()
463            .unwrap();
464        tx.repo_mut()
465            .set_wc_commit(workspace_name.to_owned(), new_commit.id().clone())
466            .unwrap();
467        tx.commit("test dirty working copy").unwrap();
468    }
469
470    #[test]
471    fn test_jj_status_clean() {
472        let (temp_dir, _workspace, _repo) = init_jj_workspace();
473
474        let status = JjStatusUtils::get_jj_status(temp_dir.path()).unwrap();
475        let expected = "\
476Working copy changes:\n<none>\nWorking copy (@): oxmtprsl 5e7ebcdf (no description set)\nParent commit (@-): zzzzzzzz 00000000 (no description set)\n";
477        assert_eq!(status.as_llm_string(), expected);
478    }
479
480    #[test]
481    fn test_jj_status_dirty_after_snapshot() {
482        let (temp_dir, _workspace, repo) = init_jj_workspace();
483        create_dirty_working_copy(&repo);
484
485        let status = JjStatusUtils::get_jj_status(temp_dir.path()).unwrap();
486        let expected = "\
487Working copy changes:\nA file.txt\nWorking copy (@): lvxkkpmk ad65a7ea (no description set)\nParent commit (@-): zzzzzzzz 00000000 (no description set)\n";
488        assert_eq!(status.as_llm_string(), expected);
489    }
490}