Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 16 additions & 21 deletions src/OpenDeepWiki/MCP/McpRepositoryTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ public static async Task<string> SearchDoc(
return JsonSerializer.Serialize(new { error = true, message = $"No documentation in language '{language}'" });

var tools = new List<AITool>();
var repoPath = BuildRepositoryPath(repoOptions.Value, resolvedOwner!, resolvedName!);
var repoPath = RepositoryWorkspacePath.Resolve(
repoOptions.Value, resolvedOwner!, resolvedName!, branch.BranchName);
if (Directory.Exists(repoPath))
{
try
Expand Down Expand Up @@ -184,7 +185,13 @@ public static async Task<string> GetRepoStructure(
if (repository == null)
return JsonSerializer.Serialize(new { error = true, message = $"Repository {resolvedOwner}/{resolvedName} not found" });

var repoPath = BuildRepositoryPath(repoOptions.Value, resolvedOwner!, resolvedName!);
// The MCP scope carries owner/repo only, so the branch owning the workspace has to
// come from the database.
var branch = await context.RepositoryBranches
.FirstOrDefaultAsync(b => b.RepositoryId == repository.Id && !b.IsDeleted, cancellationToken);

var repoPath = RepositoryWorkspacePath.Resolve(
repoOptions.Value, resolvedOwner!, resolvedName!, branch?.BranchName);
if (!Directory.Exists(repoPath))
return JsonSerializer.Serialize(new { error = true, message = "Repository workspace not found on server" });

Expand Down Expand Up @@ -236,7 +243,13 @@ public static async Task<string> ReadFile(
if (repository == null)
return JsonSerializer.Serialize(new { error = true, message = $"Repository {resolvedOwner}/{resolvedName} not found" });

var repoPath = BuildRepositoryPath(repoOptions.Value, resolvedOwner!, resolvedName!);
// The MCP scope carries owner/repo only, so the branch owning the workspace has to
// come from the database.
var branch = await context.RepositoryBranches
.FirstOrDefaultAsync(b => b.RepositoryId == repository.Id && !b.IsDeleted, cancellationToken);

var repoPath = RepositoryWorkspacePath.Resolve(
repoOptions.Value, resolvedOwner!, resolvedName!, branch?.BranchName);
if (!Directory.Exists(repoPath))
return JsonSerializer.Serialize(new { error = true, message = "Repository workspace not found on server" });

Expand Down Expand Up @@ -421,24 +434,6 @@ private static AiRequestType ParseRequestType(string? provider)
};
}

private static string BuildRepositoryPath(RepositoryAnalyzerOptions options, string owner, string repo)
{
var safeOwner = SanitizePathComponent(owner);
var safeRepo = SanitizePathComponent(repo);
return Path.Combine(options.RepositoriesDirectory, safeOwner, safeRepo, "tree");
}

private static string SanitizePathComponent(string component)
{
var sanitized = component
.Replace('/', '_')
.Replace('\\', '_')
.Replace("..", "_")
.Trim();

return string.IsNullOrWhiteSpace(sanitized) ? "_" : sanitized;
}

private static string NormalizeRelativePath(string? path)
{
if (string.IsNullOrWhiteSpace(path)) return string.Empty;
Expand Down
10 changes: 5 additions & 5 deletions src/OpenDeepWiki/Services/Chat/ChatAssistantService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ private async IAsyncEnumerable<SSEEvent> InternalStreamChatAsync(
var tools = new List<AITool>();

// Calculate repository path from Owner/Repo
var repositoryPath = GetRepositoryPath(request.Context.Owner, request.Context.Repo);
var repositoryPath = GetRepositoryPath(request.Context.Owner, request.Context.Repo, request.Context.Branch);

// Initialize GitTool with calculated repository path
GitTool? gitTool = null;
Expand Down Expand Up @@ -1252,12 +1252,12 @@ private static AiRequestType ParseRequestType(string provider)
}

/// <summary>
/// Gets the repository working directory path based on owner and repo name.
/// The repository is cloned to {RepositoriesDirectory}/{org}/{repo}/tree/
/// Gets the repository working directory path based on owner, repo name and branch.
/// The repository is cloned to {RepositoriesDirectory}/{org}/{repo}/branches/{branch}/tree/
/// </summary>
private string GetRepositoryPath(string owner, string repo)
private string GetRepositoryPath(string owner, string repo, string? branch)
{
return Path.Combine(_repoOptions.RepositoriesDirectory, owner, repo, "tree");
return RepositoryWorkspacePath.Resolve(_repoOptions, owner, repo, branch);
}

/// <summary>
Expand Down
9 changes: 5 additions & 4 deletions src/OpenDeepWiki/Services/Chat/EmbedService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ public async IAsyncEnumerable<SSEEvent> StreamEmbedChatAsync(

if (!string.IsNullOrWhiteSpace(request.Owner) && !string.IsNullOrWhiteSpace(request.Repo))
{
var repositoryPath = GetRepositoryPath(request.Owner, request.Repo);
var repositoryPath = GetRepositoryPath(request.Owner, request.Repo, request.Branch);
if (Directory.Exists(repositoryPath))
{
try
Expand Down Expand Up @@ -822,11 +822,12 @@ private static string BuildEnhancedSystemPrompt(
}

/// <summary>
/// Gets the repository working directory path based on owner and repo name.
/// Gets the repository working directory path based on owner, repo name and branch.
/// The branch is optional here; the workspace is then resolved without it.
/// </summary>
private string GetRepositoryPath(string owner, string repo)
private string GetRepositoryPath(string owner, string repo, string? branch)
{
return Path.Combine(_repoOptions.RepositoriesDirectory, owner, repo, "tree");
return RepositoryWorkspacePath.Resolve(_repoOptions, owner, repo, branch);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public class RepositoryWorkspace
{
/// <summary>
/// The absolute path to the working directory containing the repository files.
/// Format: /data/{organization}/{name}/tree/
/// Format: /data/{organization}/{name}/branches/{branch}/tree/
/// </summary>
public string WorkingDirectory { get; set; } = string.Empty;

Expand Down
6 changes: 4 additions & 2 deletions src/OpenDeepWiki/Services/Repositories/RepositoryAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -351,12 +351,14 @@ public Task<string[]> GetChangedFilesAsync(
/// </summary>
private string GetWorkingDirectory(string organization, string repositoryName, string branchName)
{
// Sanitize organization and repository names to prevent path traversal
// Sanitize organization and repository names to prevent path traversal. Unlike the
// readers, the analyzer creates the workspace, so an unusable component must throw
// instead of falling back to a placeholder.
var safeOrg = SanitizePathComponent(organization);
var safeRepo = SanitizePathComponent(repositoryName);
var safeBranch = SanitizePathComponent(branchName);

return Path.Combine(_options.RepositoriesDirectory, safeOrg, safeRepo, "branches", safeBranch, "tree");
return RepositoryWorkspacePath.ForBranch(_options, safeOrg, safeRepo, safeBranch);
}

private async Task PrepareArchiveWorkspaceAsync(
Expand Down
107 changes: 107 additions & 0 deletions src/OpenDeepWiki/Services/Repositories/RepositoryWorkspacePath.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
namespace OpenDeepWiki.Services.Repositories;

/// <summary>
/// Resolves the on-disk workspace (source checkout) of a repository.
/// Layout written by <see cref="RepositoryAnalyzer"/>:
/// {RepositoriesDirectory}/{organization}/{name}/branches/{branch}/tree/
/// </summary>
public static class RepositoryWorkspacePath
{
private const string BranchesDirectoryName = "branches";
private const string TreeDirectoryName = "tree";

/// <summary>
/// Gets the workspace path of a specific branch, whether or not it exists on disk.
/// </summary>
public static string ForBranch(
RepositoryAnalyzerOptions options,
string organization,
string repositoryName,
string branchName)
{
return Path.Combine(
RepositoryRoot(options, organization, repositoryName),
BranchesDirectoryName,
Sanitize(branchName),
TreeDirectoryName);
}

/// <summary>
/// Resolves the best workspace available to a reader (MCP tools, chat services).
/// Prefers the requested branch, then any other branch workspace, and finally the
/// flat layout used before branch workspaces were introduced. The returned path is
/// not guaranteed to exist -- callers still have to check.
/// </summary>
public static string Resolve(
RepositoryAnalyzerOptions options,
string organization,
string repositoryName,
string? branchName)
{
var repositoryRoot = RepositoryRoot(options, organization, repositoryName);
var hasBranch = !string.IsNullOrWhiteSpace(branchName);

var branchWorkspace = hasBranch
? Path.Combine(repositoryRoot, BranchesDirectoryName, Sanitize(branchName!), TreeDirectoryName)
: null;

if (branchWorkspace != null && Directory.Exists(branchWorkspace))
{
return branchWorkspace;
}

// Callers without branch context (e.g. the MCP scope carries owner/repo only)
// fall back to the most recently written branch workspace.
if (!hasBranch && NewestBranchWorkspace(repositoryRoot) is { } newestWorkspace)
{
return newestWorkspace;
}

var legacyWorkspace = Path.Combine(repositoryRoot, TreeDirectoryName);
if (Directory.Exists(legacyWorkspace))
{
return legacyWorkspace;
}

return branchWorkspace ?? legacyWorkspace;
}

private static string RepositoryRoot(
RepositoryAnalyzerOptions options,
string organization,
string repositoryName)
{
return Path.Combine(options.RepositoriesDirectory, Sanitize(organization), Sanitize(repositoryName));
}

private static string? NewestBranchWorkspace(string repositoryRoot)
{
var branchesRoot = Path.Combine(repositoryRoot, BranchesDirectoryName);
if (!Directory.Exists(branchesRoot))
{
return null;
}

return Directory.EnumerateDirectories(branchesRoot)
.Select(branchDirectory => Path.Combine(branchDirectory, TreeDirectoryName))
.Where(Directory.Exists)
.OrderByDescending(Directory.GetLastWriteTimeUtc)
.FirstOrDefault();
}

/// <summary>
/// Strips path separators and traversal sequences from a path component. Readers use a
/// placeholder for empty input instead of throwing, because they resolve untrusted
/// owner/repo values coming straight from a request.
/// </summary>
private static string Sanitize(string? component)
{
var sanitized = (component ?? string.Empty)
.Replace('/', '_')
.Replace('\\', '_')
.Replace("..", "_")
.Trim();

return string.IsNullOrWhiteSpace(sanitized) ? "_" : sanitized;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using OpenDeepWiki.Services.Repositories;
using Xunit;

namespace OpenDeepWiki.Tests.Services.Repositories;

public class RepositoryWorkspacePathTests
{
[Fact]
public void ForBranch_ShouldUseBranchScopedLayout()
{
var options = CreateOptions(CreateTempDirectory());

var path = RepositoryWorkspacePath.ForBranch(options, "acme", "widgets", "main");

Assert.Equal(
Path.Combine(options.RepositoriesDirectory, "acme", "widgets", "branches", "main", "tree"),
path);
}

[Fact]
public void ForBranch_ShouldStripPathTraversalFromComponents()
{
var options = CreateOptions(CreateTempDirectory());

var path = RepositoryWorkspacePath.ForBranch(options, "../etc", "wid/gets", @"feature\x");

// Separators become underscores first, so "../etc" ends up as "__etc".
Assert.Equal(
Path.Combine(options.RepositoriesDirectory, "__etc", "wid_gets", "branches", "feature_x", "tree"),
path);
}

[Fact]
public void Resolve_ShouldReturnRequestedBranchWorkspace()
{
var repositoriesRoot = CreateTempDirectory();
var options = CreateOptions(repositoriesRoot);
var expected = CreateWorkspace(repositoriesRoot, "acme", "widgets", "main");
CreateWorkspace(repositoriesRoot, "acme", "widgets", "develop");

Assert.Equal(expected, RepositoryWorkspacePath.Resolve(options, "acme", "widgets", "main"));
}

[Fact]
public void Resolve_WithoutBranch_ShouldReturnMostRecentlyWrittenBranchWorkspace()
{
var repositoriesRoot = CreateTempDirectory();
var options = CreateOptions(repositoriesRoot);
var stale = CreateWorkspace(repositoriesRoot, "acme", "widgets", "develop");
var recent = CreateWorkspace(repositoriesRoot, "acme", "widgets", "main");
Directory.SetLastWriteTimeUtc(stale, DateTime.UtcNow.AddDays(-1));
Directory.SetLastWriteTimeUtc(recent, DateTime.UtcNow);

Assert.Equal(recent, RepositoryWorkspacePath.Resolve(options, "acme", "widgets", branchName: null));
}

[Fact]
public void Resolve_ShouldFallBackToLegacyFlatWorkspace()
{
var repositoriesRoot = CreateTempDirectory();
var options = CreateOptions(repositoriesRoot);
var legacy = Path.Combine(repositoriesRoot, "acme", "widgets", "tree");
Directory.CreateDirectory(legacy);

Assert.Equal(legacy, RepositoryWorkspacePath.Resolve(options, "acme", "widgets", "main"));
}

[Fact]
public void Resolve_WhenNothingExists_ShouldReturnBranchWorkspacePath()
{
var options = CreateOptions(CreateTempDirectory());

var path = RepositoryWorkspacePath.Resolve(options, "acme", "widgets", "main");

Assert.Equal(RepositoryWorkspacePath.ForBranch(options, "acme", "widgets", "main"), path);
Assert.False(Directory.Exists(path));
}

private static RepositoryAnalyzerOptions CreateOptions(string repositoriesRoot)
{
return new RepositoryAnalyzerOptions { RepositoriesDirectory = repositoriesRoot };
}

private static string CreateWorkspace(string repositoriesRoot, string organization, string repository, string branch)
{
var path = Path.Combine(repositoriesRoot, organization, repository, "branches", branch, "tree");
Directory.CreateDirectory(path);
return path;
}

private static string CreateTempDirectory()
{
var path = Path.Combine(Path.GetTempPath(), "OpenDeepWiki.Tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
}