Skip to content

Commit 00ba8ee

Browse files
luong-komorebiclaude
authored andcommitted
feat: support git repositories with workspaces in subdirectories (#2400)
Co-authored-by: Claude <noreply@anthropic.com> GitOrigin-RevId: e09430d3aca0e422714fd3f770e263f0c5ce4fd9
1 parent 83398c8 commit 00ba8ee

6 files changed

Lines changed: 166 additions & 17 deletions

File tree

crates/app/src/server/api/workspaces.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ use oxy::api_types::{
2424
use oxy::config::ConfigBuilder;
2525
use oxy::github::{default_git_client, github_token_for_workspace};
2626
use oxy_auth::extractor::AuthenticatedUserExtractor;
27-
use oxy_git::{DirtyEntry, GitClient, ResetOutcome};
27+
use oxy_git::{DirtyEntry, GitClient, ResetOutcome, cli::repo::find_git_root};
2828
use oxy_shared::errors::OxyError;
2929

3030
use axum::{
@@ -363,6 +363,21 @@ async fn git_revision_info(worktree: &std::path::Path, branch: &str) -> Revision
363363
.map(|entries| entries.len() as u64)
364364
.unwrap_or(0);
365365

366+
let git_subfolder = find_git_root(worktree).and_then(|git_root| {
367+
// Canonicalize both paths so symlinks and `..` components don't
368+
// cause strip_prefix to return an empty or incorrect result.
369+
let canon_worktree = worktree
370+
.canonicalize()
371+
.unwrap_or_else(|_| worktree.to_path_buf());
372+
let canon_root = git_root.canonicalize().unwrap_or(git_root);
373+
canon_worktree
374+
.strip_prefix(&canon_root)
375+
.ok()
376+
.and_then(|p| p.to_str())
377+
.filter(|s| !s.is_empty())
378+
.map(|s| s.replace('\\', "/"))
379+
});
380+
366381
RevisionInfoResponse {
367382
base_sha: sha.clone(),
368383
head_sha: sha.clone(),
@@ -376,6 +391,7 @@ async fn git_revision_info(worktree: &std::path::Path, branch: &str) -> Revision
376391
is_in_conflict,
377392
last_sync_time: None,
378393
remote_url,
394+
git_subfolder,
379395
}
380396
}
381397

crates/core/src/api_types.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ pub struct RevisionInfoResponse {
9595
pub last_sync_time: Option<String>,
9696
#[serde(skip_serializing_if = "Option::is_none")]
9797
pub remote_url: Option<String>,
98+
/// Relative path from the git repository root to the workspace directory,
99+
/// using forward slashes. `None` when the workspace is at the git root.
100+
/// Used by the frontend to construct correct per-subfolder GitHub URLs.
101+
#[serde(skip_serializing_if = "Option::is_none")]
102+
pub git_subfolder: Option<String>,
98103
}
99104

100105
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]

crates/git/src/cli/repo.rs

Lines changed: 130 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,28 @@ use crate::cli::{config, run};
1313
static DEFAULT_BRANCH: std::sync::OnceLock<std::sync::Mutex<HashMap<PathBuf, String>>> =
1414
std::sync::OnceLock::new();
1515

16-
/// Returns `true` if `workspace_root` contains a `.git` directory or file.
16+
/// Walks up the directory tree from `path` and returns the first ancestor
17+
/// (inclusive) that contains a `.git` entry, or `None` if none is found.
18+
///
19+
/// This mirrors the discovery behaviour of the `git` binary itself: a
20+
/// workspace that lives inside a larger repository (i.e. `.git` is in a
21+
/// parent directory) is still considered part of that repository.
22+
pub fn find_git_root(path: &Path) -> Option<PathBuf> {
23+
let mut dir = path;
24+
loop {
25+
if dir.join(".git").exists() {
26+
return Some(dir.to_path_buf());
27+
}
28+
dir = dir.parent()?;
29+
}
30+
}
31+
32+
/// Returns `true` if `workspace_root` is inside a git repository.
33+
///
34+
/// The `.git` directory may be in any ancestor of `workspace_root`, not
35+
/// necessarily in `workspace_root` itself.
1736
pub fn is_git_repo(workspace_root: &Path) -> bool {
18-
workspace_root.join(".git").exists()
37+
find_git_root(workspace_root).is_some()
1938
}
2039

2140
/// Initialises a git repository at `workspace_root` if one does not already
@@ -62,23 +81,121 @@ pub async fn has_remote(workspace_root: &Path) -> bool {
6281

6382
/// Resolves the actual git directory for `root`.
6483
///
65-
/// For a regular repo, this is `root/.git/`.
66-
/// For a git worktree, `root/.git` is a file containing `gitdir: <path>` —
67-
/// we read that path so callers can find worktree-specific state.
84+
/// For a regular repo, this is `<git-root>/.git/` where `<git-root>` may be
85+
/// `root` itself or any ancestor (workspaces in a repo subfolder).
86+
/// For a git worktree, `<git-root>/.git` is a file containing `gitdir: <path>` —
87+
/// we follow the pointer so callers find the per-worktree state directory.
88+
/// This covers both "worktree at root" and "subfolder of a worktree".
6889
pub(crate) fn resolve_git_dir(root: &Path) -> PathBuf {
69-
let dot_git = root.join(".git");
70-
if dot_git.is_file()
71-
&& let Ok(content) = std::fs::read_to_string(&dot_git)
72-
&& let Some(rel) = content.trim().strip_prefix("gitdir: ")
73-
{
74-
let resolved = root.join(rel);
75-
if let Ok(canonical) = resolved.canonicalize() {
76-
return canonical;
90+
let Some(git_root) = find_git_root(root) else {
91+
return root.join(".git");
92+
};
93+
let dot_git = git_root.join(".git");
94+
follow_dot_git(&git_root, dot_git)
95+
}
96+
97+
/// Given a `<dir>/.git` path, returns the actual git object directory.
98+
///
99+
/// For a linked worktree, `.git` is a file containing `gitdir: <rel>` —
100+
/// the pointer is followed. For a regular checkout, `.git` is a directory
101+
/// and is returned unchanged.
102+
fn follow_dot_git(dir: &Path, dot_git: PathBuf) -> PathBuf {
103+
if dot_git.is_file() {
104+
if let Ok(content) = std::fs::read_to_string(&dot_git) {
105+
if let Some(rel) = content.trim().strip_prefix("gitdir: ") {
106+
let resolved = dir.join(rel);
107+
if let Ok(canonical) = resolved.canonicalize() {
108+
return canonical;
109+
}
110+
}
77111
}
78112
}
79113
dot_git
80114
}
81115

116+
#[cfg(test)]
117+
mod tests {
118+
use super::*;
119+
use std::fs;
120+
use tempfile::TempDir;
121+
122+
#[test]
123+
fn find_git_root_directly_at_path() {
124+
let dir = TempDir::new().unwrap();
125+
fs::create_dir(dir.path().join(".git")).unwrap();
126+
assert_eq!(find_git_root(dir.path()), Some(dir.path().to_path_buf()));
127+
}
128+
129+
#[test]
130+
fn find_git_root_in_subfolder() {
131+
let dir = TempDir::new().unwrap();
132+
fs::create_dir(dir.path().join(".git")).unwrap();
133+
let sub = dir.path().join("workspace").join("nested");
134+
fs::create_dir_all(&sub).unwrap();
135+
assert_eq!(find_git_root(&sub), Some(dir.path().to_path_buf()));
136+
}
137+
138+
#[test]
139+
fn find_git_root_no_repo() {
140+
let dir = TempDir::new().unwrap();
141+
assert_eq!(find_git_root(dir.path()), None);
142+
}
143+
144+
#[test]
145+
fn is_git_repo_in_subfolder() {
146+
let dir = TempDir::new().unwrap();
147+
fs::create_dir(dir.path().join(".git")).unwrap();
148+
let sub = dir.path().join("oxy-workspace");
149+
fs::create_dir(&sub).unwrap();
150+
assert!(is_git_repo(&sub));
151+
}
152+
153+
#[test]
154+
fn is_git_repo_false_outside_repo() {
155+
// Parent of a temp dir that has no .git anywhere in the chain
156+
// (the temp dir itself has no .git either).
157+
let dir = TempDir::new().unwrap();
158+
assert!(!is_git_repo(dir.path()));
159+
}
160+
161+
#[test]
162+
fn resolve_git_dir_follows_worktree_file_in_subfolder() {
163+
// Layout: repo/.git/ (real dir)
164+
// repo/.worktrees/feat/.git → file pointing to real gitdir
165+
// repo/.worktrees/feat/sub/workspace (subfolder of linked worktree)
166+
let dir = TempDir::new().unwrap();
167+
let real_gitdir = dir.path().join(".git");
168+
let worktree_gitdir = real_gitdir.join("worktrees").join("feat");
169+
fs::create_dir_all(&worktree_gitdir).unwrap();
170+
171+
let worktree_root = dir.path().join(".worktrees").join("feat");
172+
fs::create_dir_all(&worktree_root).unwrap();
173+
// .git file uses a relative path back to the real gitdir
174+
let pointer_content = format!(
175+
"gitdir: {}",
176+
worktree_gitdir
177+
.strip_prefix(&worktree_root)
178+
.unwrap_or(&worktree_gitdir)
179+
.display()
180+
);
181+
// Use the absolute path for simplicity in the test
182+
fs::write(
183+
worktree_root.join(".git"),
184+
format!("gitdir: {}", worktree_gitdir.display()),
185+
)
186+
.unwrap();
187+
let _ = pointer_content;
188+
189+
let sub = worktree_root.join("sub").join("workspace");
190+
fs::create_dir_all(&sub).unwrap();
191+
192+
// resolve_git_dir on the subfolder must follow the gitdir pointer,
193+
// not return the .git file path directly.
194+
let resolved = resolve_git_dir(&sub);
195+
assert_eq!(resolved, worktree_gitdir);
196+
}
197+
}
198+
82199
/// Returns the default branch name for `workspace_root`.
83200
///
84201
/// Resolution order:

web-app/src/pages/ide/Header/context/IdeGitContext.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ export function IdeGitProvider({
6161
remoteUrl: status.revisionInfo?.remote_url,
6262
branch,
6363
defaultBranch,
64-
isOnMain
64+
isOnMain,
65+
gitSubfolder: status.revisionInfo?.git_subfolder
6566
});
6667

6768
const refresh = useRefreshGitState(workspaceId, branch);

web-app/src/pages/ide/Header/hooks/useGithubUrls.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,33 @@ interface Args {
33
branch: string;
44
defaultBranch: string;
55
isOnMain: boolean;
6+
gitSubfolder?: string;
67
}
78

89
/**
910
* Derive `repo` and `pr` URLs from a GitHub remote URL.
1011
* Returns `{ repoUrl: null, prUrl: null }` when the remote isn't a GitHub URL
1112
* or when no remote is configured. The PR URL is null on the default branch
1213
* (no compare target).
14+
*
15+
* When the workspace lives in a subdirectory of the git repository,
16+
* `gitSubfolder` is appended to the repo URL so the link opens the correct
17+
* directory rather than the repository root.
1318
*/
14-
export function useGithubUrls({ remoteUrl, branch, defaultBranch, isOnMain }: Args) {
19+
export function useGithubUrls({ remoteUrl, branch, defaultBranch, isOnMain, gitSubfolder }: Args) {
1520
const base = (() => {
1621
if (!remoteUrl) return null;
1722
const match = remoteUrl.match(/github\.com[/:]([^/]+\/[^/.]+?)(?:\.git)?$/);
1823
return match ? `https://github.com/${match[1]}` : null;
1924
})();
2025

2126
if (!base) return { repoUrl: null, prUrl: null };
27+
const encodedSubfolder = gitSubfolder
28+
? gitSubfolder.split("/").map(encodeURIComponent).join("/")
29+
: null;
30+
const treePath = encodedSubfolder ? `${branch}/${encodedSubfolder}` : branch;
2231
return {
23-
repoUrl: `${base}/tree/${branch}`,
32+
repoUrl: `${base}/tree/${treePath}`,
2433
prUrl: isOnMain ? null : `${base}/compare/${defaultBranch}...${branch}?expand=1`
2534
};
2635
}

web-app/src/types/settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ export interface RevisionInfo {
99
is_in_conflict: boolean;
1010
last_sync_time?: string;
1111
remote_url?: string;
12+
git_subfolder?: string;
1213
}

0 commit comments

Comments
 (0)