Skip to content

Commit c196b82

Browse files
committed
feat(vcs): jj-aware git backend, incl. Jujutsu workspace support
Adds a `JjAwareGit` decorator that wraps the Git backend inside a Jujutsu workspace and routes working-copy/revision operations through jj. - Detect via `jj root`. - get_changed_files. - get_local_branch → bookmark at @ or short change-id. - get_local_branch_revision → @ commit id. - setup_hooks → still installs for git users, warns jj won't run them. - Everything else (file hashes/tree, repo root/slug) delegates to git. Auto-selected in session.rs when JjAwareGit::is_jj_workspace(). Plain-git checkouts and committed config are unaffected.
1 parent b009ad3 commit c196b82

3 files changed

Lines changed: 340 additions & 4 deletions

File tree

crates/app/src/session.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use moon_process::ProcessRegistry;
1919
use moon_project_graph::ProjectGraph;
2020
use moon_task_graph::TaskGraph;
2121
use moon_toolchain_plugin::*;
22-
use moon_vcs::{BoxedVcs, git::Git};
22+
use moon_vcs::{BoxedVcs, JjAwareGit, git::Git};
2323
use moon_workspace::{WorkspaceBuilder, WorkspaceBuilderAsync, WorkspaceBuilderContext};
2424
use moon_workspace_graph::WorkspaceGraph;
2525
use proto_core::ProtoEnvironment;
@@ -272,13 +272,23 @@ impl MoonSession {
272272
if self.vcs_adapter.get().is_none() {
273273
let config = &self.workspace_config.vcs;
274274

275-
let git: BoxedVcs = Box::new(Git::load(
275+
let git = Git::load(
276276
&self.workspace_root,
277277
&config.default_branch,
278278
&config.remote_candidates,
279-
)?);
279+
)?;
280280

281-
let _ = self.vcs_adapter.set(Arc::new(git));
281+
// Inside a Jujutsu workspace (colocated or secondary), wrap the Git
282+
// backend so working-copy/revision ops route through jj — correct
283+
// even in a secondary workspace, where git sees the parent repo.
284+
// Plain-git checkouts are unaffected.
285+
let vcs: BoxedVcs = if JjAwareGit::is_jj_workspace(&git) {
286+
Box::new(JjAwareGit::new(git))
287+
} else {
288+
Box::new(git)
289+
};
290+
291+
let _ = self.vcs_adapter.set(Arc::new(vcs));
282292
}
283293

284294
Ok(self.vcs_adapter.get().map(Arc::clone).unwrap())

crates/vcs/src/jj.rs

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
use crate::changed_files::{ChangedFiles, ChangedStatus};
2+
use crate::git::Git;
3+
use crate::process_cache::ProcessCache;
4+
use crate::vcs::{Vcs, VcsHookEnvironment};
5+
use async_trait::async_trait;
6+
use moon_common::path::{WorkspaceRelativePath, WorkspaceRelativePathBuf};
7+
use semver::Version;
8+
use std::collections::BTreeMap;
9+
use std::path::{Path, PathBuf};
10+
use std::sync::Arc;
11+
use tracing::{debug, warn};
12+
13+
/// A jj-aware wrapper around the [`Git`] backend, for **colocated** Jujutsu
14+
/// repositories (a `.jj` directory alongside `.git`).
15+
///
16+
/// The repository is still fundamentally Git — jj keeps Git's object store and
17+
/// working tree in sync — so we delegate to the Git backend for everything that
18+
/// already works correctly in colocated mode (changed files, hashing, diffs).
19+
/// We only override the few methods where Git's model gives a degraded answer
20+
/// for jj users:
21+
///
22+
/// - `get_local_branch`: jj parks Git's HEAD on `@`'s parent and doesn't keep
23+
/// a Git branch checked out, so Git reports an empty branch. We report the
24+
/// bookmark(s) at `@`, or the short change-id, instead.
25+
/// - `setup_hooks`: jj has no hook system, so moon's Git hooks never fire on
26+
/// `jj` operations. We still install them (for plain-git contributors) but
27+
/// warn that they won't run under jj.
28+
#[derive(Debug)]
29+
pub struct JjAwareGit {
30+
git: Git,
31+
jj: ProcessCache,
32+
}
33+
34+
impl JjAwareGit {
35+
/// Returns `true` if `workspace_root` is inside a Jujutsu workspace —
36+
/// colocated-at-root *or* a secondary workspace. Uses `jj root` rather than
37+
/// probing for `.jj`, so detection is correct regardless of where `.jj` and
38+
/// `.git` sit (in a secondary workspace they diverge), and it doubles as a
39+
/// "is the `jj` binary available" gate.
40+
pub fn is_jj_workspace(git: &Git) -> bool {
41+
std::process::Command::new("jj")
42+
.arg("root")
43+
.current_dir(&git.workspace_root)
44+
.output()
45+
.map(|out| out.status.success())
46+
.unwrap_or(false)
47+
}
48+
49+
pub fn new(git: Git) -> Self {
50+
debug!("Jujutsu workspace detected, enabling jj-aware VCS behavior");
51+
52+
let jj = ProcessCache::new("jj", &git.workspace_root);
53+
54+
Self { git, jj }
55+
}
56+
57+
/// Run a `jj diff … --summary` and parse it into [`ChangedFiles`]. Because
58+
/// `jj` is scoped to the workspace by cwd, this reflects *this workspace's*
59+
/// `@` — unlike the Git backend, which in a secondary workspace sees the
60+
/// parent repo and reports nothing.
61+
async fn jj_changed(&self, args: Vec<&str>) -> miette::Result<ChangedFiles> {
62+
let output = self.jj.run(args, true).await?;
63+
64+
Ok(parse_changed_files(&output))
65+
}
66+
67+
/// Resolve a human-meaningful label for the `@` change: a bookmark name if
68+
/// one points at `@`, otherwise the short change-id. Returns `None` if `jj`
69+
/// can't be queried (so callers can fall back to the Git answer).
70+
async fn resolve_working_change(&self) -> Option<String> {
71+
// Bookmarks pointing at the working-copy change, if any.
72+
if let Ok(bookmarks) = self
73+
.jj
74+
.run(["log", "--no-graph", "--color", "never", "-r", "@", "-T", "bookmarks"], true)
75+
.await
76+
{
77+
let bookmarks = bookmarks.trim();
78+
if let Some(first) = bookmarks.split_whitespace().next() {
79+
return Some(first.to_owned());
80+
}
81+
}
82+
83+
// Otherwise the stable change-id (jj's identity for the working copy).
84+
if let Ok(change_id) = self
85+
.jj
86+
.run(["log", "--no-graph", "--color", "never", "-r", "@", "-T", "change_id.short(8)"], true)
87+
.await
88+
{
89+
let change_id = change_id.trim();
90+
if !change_id.is_empty() {
91+
return Some(change_id.to_owned());
92+
}
93+
}
94+
95+
None
96+
}
97+
}
98+
99+
#[async_trait]
100+
impl Vcs for JjAwareGit {
101+
// --- jj-aware overrides -------------------------------------------------
102+
103+
async fn get_local_branch(&self) -> miette::Result<Arc<String>> {
104+
if let Some(label) = self.resolve_working_change().await {
105+
return Ok(Arc::new(label));
106+
}
107+
108+
// jj unavailable/unexpected output: fall back to Git's (likely empty) answer.
109+
self.git.get_local_branch().await
110+
}
111+
112+
async fn setup_hooks(&self) -> miette::Result<Option<VcsHookEnvironment>> {
113+
warn!(
114+
"Jujutsu checkout detected: moon's git hooks won't run on `jj` commits or operations \
115+
(jj has no hook system). They remain active for plain-git contributors."
116+
);
117+
118+
self.git.setup_hooks().await
119+
}
120+
121+
async fn get_local_branch_revision(&self) -> miette::Result<Arc<String>> {
122+
// This workspace's `@` commit. (The Git backend would report the parent
123+
// repo's HEAD in a secondary workspace.)
124+
if let Ok(commit) = self
125+
.jj
126+
.run(["log", "--no-graph", "--color", "never", "-r", "@", "-T", "commit_id"], true)
127+
.await
128+
{
129+
let commit = commit.trim();
130+
if !commit.is_empty() {
131+
return Ok(Arc::new(commit.to_owned()));
132+
}
133+
}
134+
135+
self.git.get_local_branch_revision().await
136+
}
137+
138+
async fn get_changed_files(&self) -> miette::Result<ChangedFiles> {
139+
// Working-copy changes = the `@` change's diff vs its parent. Routed
140+
// through jj so it's correct in a secondary workspace (where git sees
141+
// the parent repo and reports nothing).
142+
self.jj_changed(vec!["diff", "-r", "@", "--summary", "--color", "never"])
143+
.await
144+
}
145+
146+
async fn get_changed_files_against_previous_revision(
147+
&self,
148+
revision: &str,
149+
) -> miette::Result<ChangedFiles> {
150+
// Files changed *in* `revision` (vs its parent).
151+
self.jj_changed(vec![
152+
"diff",
153+
"-r",
154+
translate_rev(revision),
155+
"--summary",
156+
"--color",
157+
"never",
158+
])
159+
.await
160+
}
161+
162+
async fn get_changed_files_between_revisions(
163+
&self,
164+
base_revision: &str,
165+
revision: &str,
166+
) -> miette::Result<ChangedFiles> {
167+
self.jj_changed(vec![
168+
"diff",
169+
"--from",
170+
translate_rev(base_revision),
171+
"--to",
172+
translate_rev(revision),
173+
"--summary",
174+
"--color",
175+
"never",
176+
])
177+
.await
178+
}
179+
180+
// --- delegated to the Git backend --------------------------------------
181+
182+
async fn get_default_branch(&self) -> miette::Result<Arc<String>> {
183+
self.git.get_default_branch().await
184+
}
185+
186+
async fn get_default_branch_revision(&self) -> miette::Result<Arc<String>> {
187+
self.git.get_default_branch_revision().await
188+
}
189+
190+
async fn get_file_hashes(
191+
&self,
192+
files: &[WorkspaceRelativePathBuf],
193+
allow_ignored: bool,
194+
) -> miette::Result<BTreeMap<WorkspaceRelativePathBuf, String>> {
195+
self.git.get_file_hashes(files, allow_ignored).await
196+
}
197+
198+
async fn get_file_tree(
199+
&self,
200+
dir: &WorkspaceRelativePath,
201+
) -> miette::Result<Vec<WorkspaceRelativePathBuf>> {
202+
self.git.get_file_tree(dir).await
203+
}
204+
205+
async fn get_repository_root(&self) -> miette::Result<PathBuf> {
206+
self.git.get_repository_root().await
207+
}
208+
209+
async fn get_repository_slug(&self) -> miette::Result<Arc<String>> {
210+
self.git.get_repository_slug().await
211+
}
212+
213+
async fn get_version(&self) -> miette::Result<Version> {
214+
self.git.get_version().await
215+
}
216+
217+
async fn get_working_root(&self) -> miette::Result<PathBuf> {
218+
self.git.get_working_root().await
219+
}
220+
221+
fn is_default_branch(&self, branch: &str) -> bool {
222+
self.git.is_default_branch(branch)
223+
}
224+
225+
fn is_enabled(&self) -> bool {
226+
self.git.is_enabled()
227+
}
228+
229+
fn is_ignored(&self, file: &Path) -> bool {
230+
self.git.is_ignored(file)
231+
}
232+
233+
async fn is_shallow_checkout(&self) -> miette::Result<bool> {
234+
self.git.is_shallow_checkout().await
235+
}
236+
237+
async fn teardown_hooks(&self) -> miette::Result<()> {
238+
self.git.teardown_hooks().await
239+
}
240+
}
241+
242+
/// Translate the git-ish default revision moon passes (`HEAD`) to jj's `@`.
243+
/// Concrete commit ids and bookmark names pass through (jj accepts both).
244+
fn translate_rev(rev: &str) -> &str {
245+
if rev == "HEAD" { "@" } else { rev }
246+
}
247+
248+
/// Parse `jj diff --summary` output — `"<status> <path>"` lines — into
249+
/// [`ChangedFiles`]. Paths are workspace-relative (jj emits them relative to
250+
/// the workspace root). jj has no "untracked" state (the working copy is
251+
/// auto-snapshotted into `@`), so new files surface as `Added`.
252+
fn parse_changed_files(output: &str) -> ChangedFiles {
253+
let mut changed = ChangedFiles::default();
254+
255+
for line in output.lines() {
256+
let Some((status, path)) = line.trim_end().split_once(' ') else {
257+
continue;
258+
};
259+
260+
let status = match status {
261+
"A" => ChangedStatus::Added,
262+
"D" => ChangedStatus::Deleted,
263+
// M, plus R (rename) / C (copy) keyed on their destination.
264+
_ => ChangedStatus::Modified,
265+
};
266+
267+
// Renames/copies render as "old => new"; key on the destination path.
268+
let path = path.rsplit(" => ").next().unwrap_or(path).trim();
269+
270+
changed
271+
.files
272+
.entry(WorkspaceRelativePathBuf::from(path))
273+
.or_default()
274+
.push(status);
275+
}
276+
277+
changed
278+
}
279+
280+
#[cfg(test)]
281+
mod tests {
282+
use super::*;
283+
284+
// Local/manual demo: must run inside a colocated jj checkout (e.g. this repo).
285+
// Plain `cargo test` skips it so CI/non-jj checkouts stay green.
286+
#[tokio::test(flavor = "multi_thread")]
287+
#[ignore = "requires a colocated jj checkout; run with `-- --ignored --nocapture`"]
288+
async fn reports_working_change_on_colocated_repo() {
289+
let cwd = std::env::current_dir().unwrap();
290+
let git = Git::load(&cwd, "master", &["origin".to_string()]).unwrap();
291+
292+
assert!(JjAwareGit::is_jj_workspace(&git), "expected a jj workspace");
293+
294+
let plain_git_branch = git.get_local_branch().await.unwrap();
295+
let vcs = JjAwareGit::new(git);
296+
let jj_branch = vcs.get_local_branch().await.unwrap();
297+
298+
println!("plain git get_local_branch() = {plain_git_branch:?}");
299+
println!("jj-aware get_local_branch() = {jj_branch:?}");
300+
301+
assert!(!jj_branch.is_empty(), "expected a jj bookmark/change-id, not an empty branch");
302+
}
303+
304+
#[test]
305+
fn parses_jj_diff_summary() {
306+
let changed =
307+
parse_changed_files("A apps/web/new.rb\nM lib/util.rb\nD old.rb\nR a.rb => b.rb\n");
308+
309+
assert_eq!(
310+
changed.files.get(&WorkspaceRelativePathBuf::from("apps/web/new.rb")),
311+
Some(&vec![ChangedStatus::Added])
312+
);
313+
assert_eq!(
314+
changed.files.get(&WorkspaceRelativePathBuf::from("lib/util.rb")),
315+
Some(&vec![ChangedStatus::Modified])
316+
);
317+
assert_eq!(
318+
changed.files.get(&WorkspaceRelativePathBuf::from("old.rb")),
319+
Some(&vec![ChangedStatus::Deleted])
320+
);
321+
// Rename keyed on the destination path.
322+
assert!(changed.files.contains_key(&WorkspaceRelativePathBuf::from("b.rb")));
323+
}
324+
}

crates/vcs/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
pub mod git;
2+
pub mod jj;
23

34
mod changed_files;
45
mod process_cache;
56
mod vcs;
67

78
pub use changed_files::*;
9+
pub use jj::JjAwareGit;
810
pub use vcs::*;
911

1012
pub type BoxedVcs = Box<dyn Vcs + Send + Sync + 'static>;

0 commit comments

Comments
 (0)