E04 -- Workspace Lifecycle + File Operations
E04 -- Workspace Lifecycle + File Operations
Depends on: E01 (Git Adapter), E03 (App Setup)
1. Goal
Deliver editable workspaces with Git-backed file persistence and guardrails. Users get a mutable branch per app where they can browse the file tree, read and write files, and synchronize their workspace with the upstream baseline. All file mutations flow through gitaly-rs and produce real Git commits. The backend reserves multi-workspace APIs even though the v1 UI surfaces only one default workspace per user per app.
From the backlog:
| ID | Item |
|---|---|
| E04-01 | Create default workspace branch
(ws/<user>/<app>) on first use |
| E04-02 | Workspace CRUD (reserve multi-workspace APIs, UI can hide extras) |
| E04-03 | File tree/list/read/write/delete endpoints using
path query/body |
| E04-04 | Guardrails for blocked paths and max file size |
| E04-05 | Workspace reset/sync-integration flow with rebase/merge fallback |
| E04-06 | Conflict detection primitives for later changeset/release flows |
2. Dependencies
| Dependency | What we need from it |
|---|---|
| E01 -- Git Adapter | GitalyClient with a connected tonic
Channel. Helper
app_to_gitaly_repo(). |
| E03 -- App Setup | App document with repo_path,
blocked_paths, file_size_limit_bytes,
baseline_mode, canonical_env_id,
commit_mode_default. AppRepo for
lookup. |
| E02 -- Auth (transitive via E03) | AuthUser extractor, require_role()
guard. Every workspace endpoint requires at least
Role::User. |
3. Rust Types
3.1 Domain types
(conman-core)
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
// -- Workspace -----------------------------------------------------------
/// Represents the kind of Git ref the workspace was branched from.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BaseRefType {
/// Branched from a branch (e.g. app `integration_branch`).
Branch,
/// Branched from a release tag (e.g. "r2025.06.01.1").
Tag,
/// Branched from a specific commit SHA.
Commit,
}
/// A user-owned mutable branch used to edit app files.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workspace {
/// MongoDB ObjectId, serialized as hex string in API responses.
pub id: ObjectId,
/// The app this workspace belongs to.
pub app_id: ObjectId,
/// The user who owns this workspace.
pub owner_user_id: ObjectId,
/// Full Git branch name, e.g. "ws/alice/my-app".
pub branch_name: String,
/// Optional human-readable title. Reserved for multi-workspace UI.
pub title: Option<String>,
/// True for the auto-created default workspace per user per app.
pub is_default: bool,
/// What kind of ref this workspace was branched from.
pub base_ref_type: BaseRefType,
/// The ref value (branch name, tag name, or commit SHA).
pub base_ref_value: String,
/// Current HEAD commit SHA of the workspace branch.
pub head_sha: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
// -- File types ----------------------------------------------------------
/// The kind of entry in a file tree listing.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FileEntryType {
File,
Dir,
}
/// A single entry returned by the file tree listing endpoint.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileEntry {
/// Relative path from repo root, e.g. "config/entities/foo.json".
pub path: String,
/// Whether this entry is a file or directory.
#[serde(rename = "type")]
pub entry_type: FileEntryType,
/// Size in bytes. 0 for directories.
pub size: i64,
/// Git object ID (blob OID for files, tree OID for directories).
pub oid: String,
}
/// Full file content returned by the file read endpoint.
#[derive(Debug, Clone)]
pub struct FileContent {
/// Relative path from repo root.
pub path: String,
/// Raw file bytes.
pub content: Vec<u8>,
/// Size in bytes.
pub size: i64,
}
/// An action to perform when committing files via UserCommitFiles.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FileAction {
Create,
Update,
Delete,
}
/// Result of a sync-integration or rebase operation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConflictStatus {
/// True if the sync completed without conflicts.
pub clean: bool,
/// New HEAD SHA after successful sync, or the pre-sync HEAD on conflict.
pub head_sha: String,
/// List of conflicting file paths, empty when `clean` is true.
pub conflicting_paths: Vec<String>,
/// Human-readable summary, e.g. "Rebased 3 commits onto the integration branch".
pub message: String,
}3.2 API types
(conman-api)
use serde::{Deserialize, Serialize};
// -- Requests ------------------------------------------------------------
/// POST /api/apps/:appId/workspaces
#[derive(Debug, Deserialize)]
pub struct CreateWorkspaceRequest {
/// Optional title. Omit for default workspace.
pub title: Option<String>,
/// Optional branch name override. Server generates default if omitted.
pub branch_name: Option<String>,
}
/// PATCH /api/apps/:appId/workspaces/:workspaceId
#[derive(Debug, Deserialize)]
pub struct UpdateWorkspaceRequest {
pub title: Option<String>,
}
/// PUT /api/apps/:appId/workspaces/:workspaceId/files
#[derive(Debug, Deserialize)]
pub struct WriteFileRequest {
/// Relative path from repo root.
pub path: String,
/// Base64-encoded file content.
pub content: String,
/// Optional commit message. Server generates a default if omitted.
pub message: Option<String>,
}
/// DELETE /api/apps/:appId/workspaces/:workspaceId/files
#[derive(Debug, Deserialize)]
pub struct DeleteFileRequest {
/// Relative path from repo root.
pub path: String,
/// Optional commit message.
pub message: Option<String>,
}
/// POST /api/apps/:appId/workspaces/:workspaceId/checkpoints
#[derive(Debug, Deserialize)]
pub struct CreateCheckpointRequest {
/// Commit message for the checkpoint.
pub message: Option<String>,
}
/// Query params for GET .../files?path=
#[derive(Debug, Deserialize)]
pub struct FilePathQuery {
/// Path to list or read. Empty or "/" means repo root.
#[serde(default)]
pub path: String,
}
// -- Responses -----------------------------------------------------------
/// Single workspace in API responses.
#[derive(Debug, Serialize)]
pub struct WorkspaceResponse {
pub id: String,
pub app_id: String,
pub owner_user_id: String,
pub branch_name: String,
pub title: Option<String>,
pub is_default: bool,
pub base_ref_type: String,
pub base_ref_value: String,
pub head_sha: String,
pub created_at: String,
pub updated_at: String,
}
/// GET .../files?path= when path points to a directory.
#[derive(Debug, Serialize)]
pub struct FileTreeResponse {
pub path: String,
pub entries: Vec<FileEntry>,
}
/// GET .../files?path= when path points to a file.
#[derive(Debug, Serialize)]
pub struct FileContentResponse {
pub path: String,
/// Base64-encoded content.
pub content: String,
pub size: i64,
pub oid: String,
}
/// Response for write/delete file operations.
#[derive(Debug, Serialize)]
pub struct FileWriteResponse {
pub commit_sha: String,
pub path: String,
}
/// POST .../sync-integration response.
#[derive(Debug, Serialize)]
pub struct SyncMainResponse {
pub clean: bool,
pub head_sha: String,
pub conflicting_paths: Vec<String>,
pub message: String,
}
/// POST .../reset response.
#[derive(Debug, Serialize)]
pub struct ResetResponse {
pub head_sha: String,
pub message: String,
}
/// POST .../checkpoints response.
#[derive(Debug, Serialize)]
pub struct CheckpointResponse {
pub commit_sha: String,
pub message: String,
}4. Database
4.1 Collection:
workspaces
{
"_id": ObjectId("..."),
"app_id": ObjectId("..."),
"owner_user_id": ObjectId("..."),
"branch_name": "ws/alice/my-app",
"title": null,
"is_default": true,
"base_ref_type": "branch",
"base_ref_value": "integration_branch",
"head_sha": "a1b2c3d4e5f6...",
"created_at": ISODate("2026-02-25T10:00:00Z"),
"updated_at": ISODate("2026-02-25T12:30:00Z")
}4.2 Indexes
| Index | Fields | Options | Purpose |
|---|---|---|---|
idx_ws_app_branch |
{ app_id: 1, branch_name: 1 } |
unique: true |
Prevent duplicate branches per app |
idx_ws_app_owner_default |
{ app_id: 1, owner_user_id: 1, is_default: 1 } |
unique: true, partialFilterExpression: { is_default: true } |
At most one default workspace per user per app |
idx_ws_app_owner |
{ app_id: 1, owner_user_id: 1 } |
-- | Efficient lookup for listing a user's workspaces in an app |
idx_ws_app |
{ app_id: 1 } |
-- | Paginated listing of all workspaces in an app |
4.3 Repository:
WorkspaceRepo
pub struct WorkspaceRepo {
collection: Collection<Workspace>,
}
impl WorkspaceRepo {
pub async fn ensure_indexes(&self) -> Result<(), ConmanError>;
pub async fn insert(&self, workspace: &Workspace) -> Result<(), ConmanError>;
pub async fn find_by_id(&self, id: ObjectId) -> Result<Option<Workspace>, ConmanError>;
pub async fn find_default(&self, app_id: ObjectId, user_id: ObjectId) -> Result<Option<Workspace>, ConmanError>;
pub async fn find_by_app_and_branch(&self, app_id: ObjectId, branch: &str) -> Result<Option<Workspace>, ConmanError>;
pub async fn list_by_app(&self, app_id: ObjectId, page: u64, limit: u64) -> Result<(Vec<Workspace>, u64), ConmanError>;
pub async fn update_head_sha(&self, id: ObjectId, head_sha: &str) -> Result<(), ConmanError>;
pub async fn update_title(&self, id: ObjectId, title: Option<&str>) -> Result<(), ConmanError>;
pub async fn delete(&self, id: ObjectId) -> Result<(), ConmanError>;
}5. API Endpoints
All endpoints require
Authorization: Bearer <token> and at minimum
Role::User on the target app.
5.1 Workspace CRUD
| Method | Path | Handler | Description |
|---|---|---|---|
GET |
/api/apps/:appId/workspaces?page=&limit= |
list_workspaces |
List workspaces for the app. Paginated. |
POST |
/api/apps/:appId/workspaces |
create_workspace |
Create a new workspace. Creates Git branch. Returns
201. |
GET |
/api/apps/:appId/workspaces/:workspaceId |
get_workspace |
Get single workspace by ID. |
PATCH |
/api/apps/:appId/workspaces/:workspaceId |
update_workspace |
Update workspace title. |
POST |
/api/apps/:appId/workspaces/:workspaceId/reset |
reset_workspace |
Reset workspace branch to baseline. |
POST |
/api/apps/:appId/workspaces/:workspaceId/sync-integration |
sync_workspace |
Rebase/merge workspace onto current integration branch. |
5.2 File Operations
| Method | Path | Handler | Description |
|---|---|---|---|
GET |
/api/apps/:appId/workspaces/:workspaceId/files?path= |
get_files |
If path is a directory: list tree entries. If
path is a file: return content. |
PUT |
/api/apps/:appId/workspaces/:workspaceId/files |
write_file |
Create or update a file. Body:
{ path, content, message? }. |
DELETE |
/api/apps/:appId/workspaces/:workspaceId/files |
delete_file |
Delete a file. Body: { path, message? }. |
POST |
/api/apps/:appId/workspaces/:workspaceId/checkpoints |
create_checkpoint |
Commit current working state (for
manual_checkpoint mode). |
5.3 Ownership and authorization rules
- Any authenticated user can list and read workspaces in apps they belong to.
- Users can only write to their own workspaces (or
app_admincan write to any). resetandsync-integrationfollow the same ownership rules.- Default workspace is auto-created on first
POST /workspacesif none exists for the user.
6. Business Logic
6.1 Default workspace creation
When a user calls POST /api/apps/:appId/workspaces
without specifying a branch_name, or when any
file/changeset operation references a workspace that does not yet
exist:
- Resolve the app's baseline ref via
resolve_baseline(app). - Derive the branch name:
ws/<user_email_prefix>/<app_name>.user_email_prefix= everything before@in the user's email, lowercased, non-alphanumeric replaced with-.app_name=app.namelowercased, spaces replaced with-.
- Call
OperationService.UserCreateBranchto create the branch from the baseline commit. - Insert
Workspacedocument withis_default: true. - Return
201 Created.
Branch name sanitization must reject names containing
.., starting with -, or containing
whitespace.
6.2 File write flow
- Validate
pathis not inapp.blocked_paths(glob matching viaglobset). - Validate
contentdecoded size does not exceedapp.file_size_limit_bytes(default 5 MB). - Determine
FileAction--Createif blob does not exist at path in HEAD,Updateotherwise. - Call
OperationService.UserCommitFileswith a single action. - Update
workspace.head_shain MongoDB to the new commit SHA. - Emit audit event:
workspace_file_write. - Return new commit SHA.
6.3 File tree listing
- Call
CommitService.GetTreeEntrieswithrevision = workspace.head_sha,path = requested_path. - Map
TreeEntryresults toFileEntrydomain structs. - Sort: directories first, then alphabetical.
6.4 File read
- Call
CommitService.TreeEntry(single entry) to get the blob OID and verify the path exists. - Call
BlobService.GetBlobswith the blob's revision+path to stream the content. - Return base64-encoded content to the client.
6.5 File delete
- Validate
pathis not inapp.blocked_paths. - Call
OperationService.UserCommitFileswithActionType::DELETE. - Update
workspace.head_sha. - Emit audit event:
workspace_file_delete.
6.6 Workspace reset
- Resolve the app's current baseline ref.
- Call
OperationService.UserCommitFilesorRefService.UpdateReferencesto force the workspace branch back to the baseline commit. - Update
workspace.head_shaandbase_ref_valuein MongoDB. - Emit audit event:
workspace_reset.
6.7 Sync-integration (rebase onto the integration branch)
- Find the workspace's current
head_shaand the currentintegration_branchHEAD. - Check if workspace is already up to date via
CommitService.CommitIsAncestor(if integration branch HEAD is ancestor of workspace HEAD, it is already up to date). - Call
OperationService.UserRebaseToRefwith:source_sha= workspacehead_shafirst_parent_ref=refs/heads/<integration_branch>target_ref= workspace branch ref
- If rebase succeeds: update
workspace.head_sha, returnConflictStatus { clean: true }. - If rebase fails with conflict: run
DiffService.FindChangedPathsbetween the integration branch and workspace to identify conflicting paths. ReturnConflictStatus { clean: false, conflicting_paths }. - Emit audit event:
workspace_sync.
6.8 Checkpoint (manual_checkpoint mode)
- Check that
app.commit_mode_default == "manual_checkpoint"(or user override). - Call
OperationService.UserCommitFileswith an empty action list and the provided message to create a commit on the workspace branch. (Alternatively, if there are staged changes in the working state, they get committed here.) - Update
workspace.head_sha. - Emit audit event:
workspace_checkpoint.
6.9 Blocked path validation
/// Returns Err(ConmanError::Validation) if the path matches any blocked pattern.
pub fn validate_path_not_blocked(path: &str, blocked_paths: &[String]) -> Result<(), ConmanError> {
let glob_set = globset::GlobSetBuilder::new();
for pattern in blocked_paths {
glob_set.add(globset::Glob::new(pattern)?);
}
let set = glob_set.build()?;
if set.is_match(path) {
return Err(ConmanError::Validation {
message: format!("path '{}' is blocked by app guardrails", path),
});
}
Ok(())
}Default blocked paths: .git/**,
.gitignore, .github/**.
6.10 File size validation
pub fn validate_file_size(content_len: usize, limit_bytes: u64) -> Result<(), ConmanError> {
if content_len as u64 > limit_bytes {
return Err(ConmanError::Validation {
message: format!(
"file size {} exceeds limit of {} bytes",
content_len, limit_bytes
),
});
}
Ok(())
}Default limit: 5,242,880 bytes (5 MB).
7. Gitaly-rs Integration
All proto definitions below are from the gitaly-rs repository
at /Volumes/External/code-external/gitaly/proto/.
Included verbatim so this epic is self-contained.
7.1 Shared types
(shared.proto)
message Repository {
string storage_name = 2;
string relative_path = 3;
string git_object_directory = 4;
repeated string git_alternate_object_directories = 5;
string gl_repository = 6;
string gl_project_path = 8;
}
message User {
string gl_id = 1;
bytes name = 2;
bytes email = 3;
string gl_username = 4;
string timezone = 5;
}
message Branch {
bytes name = 1;
GitCommit target_commit = 2;
}
message GitCommit {
string id = 1;
bytes subject = 2;
bytes body = 3;
CommitAuthor author = 4;
CommitAuthor committer = 5;
repeated string parent_ids = 6;
int64 body_size = 7;
SignatureType signature_type = 8;
string tree_id = 9;
repeated CommitTrailer trailers = 10;
CommitStatInfo short_stats = 11;
repeated bytes referenced_by = 12;
string encoding = 13;
}
message CommitAuthor {
bytes name = 1;
bytes email = 2;
google.protobuf.Timestamp date = 3;
bytes timezone = 4;
}
message PaginationParameter {
string page_token = 1;
int32 limit = 2;
}
message PaginationCursor {
string next_cursor = 1;
}
enum ObjectType {
UNKNOWN = 0;
COMMIT = 1;
BLOB = 2;
TREE = 3;
TAG = 4;
}7.2
OperationService.UserCreateBranch -- Create workspace
branch
Used in: E04-01 (default workspace creation)
// operations.proto
rpc UserCreateBranch(UserCreateBranchRequest) returns (UserCreateBranchResponse);
message UserCreateBranchRequest {
// Repository in which the branch should be created.
Repository repository = 1;
// Name of the branch to create (e.g. "ws/alice/my-app").
bytes branch_name = 2;
// User to execute the action as.
User user = 3;
// Git revision to start the branch at (e.g. "refs/heads/<integration_branch>" or a SHA).
bytes start_point = 4;
}
message UserCreateBranchResponse {
// The created branch, including target_commit.
Branch branch = 1;
}
message UserCreateBranchError {
oneof error {
CustomHookError custom_hook = 1;
}
}Conman usage:
async fn create_workspace_branch(
client: &GitalyClient,
repo: &gitaly::Repository,
user: &gitaly::User,
branch_name: &str,
start_point: &str,
) -> Result<String, ConmanError> {
let resp = client
.operation_service()
.user_create_branch(UserCreateBranchRequest {
repository: Some(repo.clone()),
branch_name: branch_name.as_bytes().to_vec(),
user: Some(user.clone()),
start_point: start_point.as_bytes().to_vec(),
})
.await?;
let branch = resp.into_inner().branch.ok_or(ConmanError::Git {
message: "branch creation returned empty response".into(),
})?;
let commit = branch.target_commit.ok_or(ConmanError::Git {
message: "branch has no target commit".into(),
})?;
Ok(commit.id)
}7.3
OperationService.UserCommitFiles -- Write/delete
files
Used in: E04-03 (file write/delete), E04-08 (checkpoint)
// operations.proto
rpc UserCommitFiles(stream UserCommitFilesRequest) returns (UserCommitFilesResponse);
message UserCommitFilesRequestHeader {
Repository repository = 1;
User user = 2;
// Branch to commit to (e.g. "ws/alice/my-app").
bytes branch_name = 3;
bytes commit_message = 4;
bytes commit_author_name = 5;
bytes commit_author_email = 6;
// Optional: parent branch. Takes priority over branch_name for parent lookup.
bytes start_branch_name = 7;
Repository start_repository = 8;
bool force = 9;
// Optional: explicit parent SHA. Takes priority over start_branch_name.
string start_sha = 10;
google.protobuf.Timestamp timestamp = 11;
// Expected current OID of branch_name for optimistic concurrency.
string expected_old_oid = 12;
bool sign = 13;
}
message UserCommitFilesActionHeader {
enum ActionType {
CREATE = 0;
CREATE_DIR = 1;
UPDATE = 2;
MOVE = 3;
DELETE = 4;
CHMOD = 5;
}
ActionType action = 1;
bytes file_path = 2;
bytes previous_path = 3;
bool base64_content = 4;
bool execute_filemode = 5;
bool infer_content = 6;
}
message UserCommitFilesAction {
oneof user_commit_files_action_payload {
UserCommitFilesActionHeader header = 1;
bytes content = 2;
}
}
message UserCommitFilesRequest {
oneof user_commit_files_request_payload {
UserCommitFilesRequestHeader header = 1;
UserCommitFilesAction action = 2;
}
}
message UserCommitFilesResponse {
OperationBranchUpdate branch_update = 1;
string index_error = 2;
string pre_receive_error = 3;
}
message OperationBranchUpdate {
string commit_id = 1;
bool repo_created = 2;
bool branch_created = 3;
}
message UserCommitFilesError {
oneof error {
AccessCheckError access_check = 1;
IndexError index_update = 2;
CustomHookError custom_hook = 3;
}
}Conman usage (write file):
async fn commit_file(
client: &GitalyClient,
repo: &gitaly::Repository,
user: &gitaly::User,
branch_name: &str,
file_path: &str,
content: &[u8],
action: FileAction,
message: &str,
expected_old_oid: &str,
) -> Result<String, ConmanError> {
let action_type = match action {
FileAction::Create => ActionType::Create as i32,
FileAction::Update => ActionType::Update as i32,
FileAction::Delete => ActionType::Delete as i32,
};
// Stream: header first, then action header, then content chunks
let header_msg = UserCommitFilesRequest {
user_commit_files_request_payload: Some(Header(UserCommitFilesRequestHeader {
repository: Some(repo.clone()),
user: Some(user.clone()),
branch_name: branch_name.as_bytes().to_vec(),
commit_message: message.as_bytes().to_vec(),
expected_old_oid: expected_old_oid.into(),
..Default::default()
})),
};
let action_header_msg = UserCommitFilesRequest {
user_commit_files_request_payload: Some(Action(UserCommitFilesAction {
user_commit_files_action_payload: Some(ActionHeader(UserCommitFilesActionHeader {
action: action_type,
file_path: file_path.as_bytes().to_vec(),
base64_content: false,
..Default::default()
})),
})),
};
let content_msg = UserCommitFilesRequest {
user_commit_files_request_payload: Some(Action(UserCommitFilesAction {
user_commit_files_action_payload: Some(Content(content.to_vec())),
})),
};
let stream = tokio_stream::iter(vec![header_msg, action_header_msg, content_msg]);
let resp = client
.operation_service()
.user_commit_files(stream)
.await?;
let inner = resp.into_inner();
if !inner.index_error.is_empty() {
return Err(ConmanError::Git { message: inner.index_error });
}
let commit_id = inner
.branch_update
.ok_or(ConmanError::Git { message: "no branch update".into() })?
.commit_id;
Ok(commit_id)
}7.4
CommitService.GetTreeEntries -- List file tree
Used in: E04-03 (file tree listing)
// commit.proto
rpc GetTreeEntries(GetTreeEntriesRequest) returns (stream GetTreeEntriesResponse);
message GetTreeEntriesRequest {
enum SortBy {
DEFAULT = 0;
TREES_FIRST = 1;
FILESYSTEM = 2;
}
Repository repository = 1;
// The commitish to read the tree from (workspace HEAD SHA or branch name).
bytes revision = 2;
// Path relative to repo root. Empty or "." for root.
bytes path = 3;
// Set true to recursively list all entries under path.
bool recursive = 4;
SortBy sort = 5;
PaginationParameter pagination_params = 6;
bool skip_flat_paths = 7;
}
message GetTreeEntriesResponse {
repeated TreeEntry entries = 1;
PaginationCursor pagination_cursor = 2;
}
message TreeEntry {
enum EntryType {
BLOB = 0;
TREE = 1;
COMMIT = 3;
}
string oid = 1;
bytes path = 3;
EntryType type = 4;
int32 mode = 5;
string commit_oid = 6;
bytes flat_path = 7;
}
message GetTreeEntriesError {
oneof error {
ResolveRevisionError resolve_tree = 1;
PathError path = 2;
}
}Conman usage:
async fn list_tree_entries(
client: &GitalyClient,
repo: &gitaly::Repository,
revision: &str,
path: &str,
) -> Result<Vec<FileEntry>, ConmanError> {
let mut stream = client
.commit_service()
.get_tree_entries(GetTreeEntriesRequest {
repository: Some(repo.clone()),
revision: revision.as_bytes().to_vec(),
path: path.as_bytes().to_vec(),
recursive: false,
sort: SortBy::TreesFirst as i32,
skip_flat_paths: true,
..Default::default()
})
.await?
.into_inner();
let mut entries = Vec::new();
while let Some(resp) = stream.message().await? {
for te in resp.entries {
entries.push(FileEntry {
path: String::from_utf8_lossy(&te.path).to_string(),
entry_type: match te.r#type() {
TreeEntryType::Tree => FileEntryType::Dir,
_ => FileEntryType::File,
},
size: 0, // size not included in tree entries; use GetBlob if needed
oid: te.oid,
});
}
}
Ok(entries)
}7.5
BlobService.GetBlobs -- Read file content
Used in: E04-03 (file read)
// blob.proto
rpc GetBlobs(GetBlobsRequest) returns (stream GetBlobsResponse);
message GetBlobsRequest {
message RevisionPath {
// Revision that identifies the tree-ish (e.g. workspace HEAD SHA).
string revision = 1;
// Path relative to the tree-ish (e.g. "config/entities/foo.json").
bytes path = 2;
}
Repository repository = 1;
repeated RevisionPath revision_paths = 2;
// Max bytes per blob. -1 for unlimited.
int64 limit = 3;
}
message GetBlobsResponse {
int64 size = 1;
bytes data = 2;
string oid = 3;
bool is_submodule = 4;
int32 mode = 5;
string revision = 6;
bytes path = 7;
ObjectType type = 8;
}Conman usage:
async fn read_file(
client: &GitalyClient,
repo: &gitaly::Repository,
revision: &str,
path: &str,
) -> Result<FileContent, ConmanError> {
let mut stream = client
.blob_service()
.get_blobs(GetBlobsRequest {
repository: Some(repo.clone()),
revision_paths: vec![RevisionPath {
revision: revision.to_string(),
path: path.as_bytes().to_vec(),
}],
limit: -1, // full content
})
.await?
.into_inner();
let mut content = Vec::new();
let mut size = 0i64;
let mut found = false;
while let Some(resp) = stream.message().await? {
if !resp.oid.is_empty() {
found = true;
size = resp.size;
}
if !resp.data.is_empty() {
content.extend_from_slice(&resp.data);
}
}
if !found {
return Err(ConmanError::NotFound {
entity: "file",
id: path.to_string(),
});
}
Ok(FileContent {
path: path.to_string(),
content,
size,
})
}7.6
RefService.FindBranch -- Check branch exists, get
HEAD
Used in: E04-01 (branch existence check), E04-05 (reset, sync-integration)
// ref.proto
rpc FindBranch(FindBranchRequest) returns (FindBranchResponse);
message FindBranchRequest {
Repository repository = 1;
// Branch name without "refs/heads/" prefix.
bytes name = 2;
}
message FindBranchResponse {
// The found branch. Nil if not found.
Branch branch = 1;
}Conman usage:
async fn find_branch_head(
client: &GitalyClient,
repo: &gitaly::Repository,
branch_name: &str,
) -> Result<Option<String>, ConmanError> {
let resp = client
.ref_service()
.find_branch(FindBranchRequest {
repository: Some(repo.clone()),
name: branch_name.as_bytes().to_vec(),
})
.await?
.into_inner();
Ok(resp.branch.and_then(|b| b.target_commit.map(|c| c.id)))
}7.7
OperationService.UserRebaseToRef -- Sync workspace
with integration branch
Used in: E04-05 (sync-integration)
// operations.proto
rpc UserRebaseToRef(UserRebaseToRefRequest) returns (UserRebaseToRefResponse);
message UserRebaseToRefRequest {
Repository repository = 1;
User user = 2;
// Object ID of the commit to be rebased (workspace HEAD).
string source_sha = 3;
// Fully qualified ref to overwrite with the rebased result
// (e.g. "refs/heads/ws/alice/my-app").
bytes target_ref = 5;
// Ref on top of which source_sha is rebased
// (e.g. "refs/heads/<integration_branch>").
bytes first_parent_ref = 7;
google.protobuf.Timestamp timestamp = 9;
// Expected current OID of target_ref for race safety.
string expected_old_oid = 10;
}
message UserRebaseToRefResponse {
// Object ID of the HEAD of the rebased ref.
string commit_id = 1;
}Conman usage:
async fn rebase_workspace_onto_integration(
client: &GitalyClient,
repo: &gitaly::Repository,
user: &gitaly::User,
workspace: &Workspace,
) -> Result<ConflictStatus, ConmanError> {
let target_ref = format!("refs/heads/{}", workspace.branch_name);
match client
.operation_service()
.user_rebase_to_ref(UserRebaseToRefRequest {
repository: Some(repo.clone()),
user: Some(user.clone()),
source_sha: workspace.head_sha.clone(),
target_ref: target_ref.as_bytes().to_vec(),
first_parent_ref: b"refs/heads/<integration_branch>".to_vec(),
expected_old_oid: workspace.head_sha.clone(),
..Default::default()
})
.await
{
Ok(resp) => Ok(ConflictStatus {
clean: true,
head_sha: resp.into_inner().commit_id,
conflicting_paths: vec![],
message: "Successfully rebased onto the integration branch".into(),
}),
Err(status) if is_conflict_error(&status) => {
let conflicts = detect_conflicting_paths(client, repo, workspace).await?;
Ok(ConflictStatus {
clean: false,
head_sha: workspace.head_sha.clone(),
conflicting_paths: conflicts,
message: "Rebase failed due to conflicts".into(),
})
}
Err(e) => Err(ConmanError::Git {
message: format!("rebase failed: {}", e),
}),
}
}7.8
DiffService.CommitDiff -- Detect conflicts
Used in: E04-06 (conflict detection primitives)
// diff.proto
rpc CommitDiff(CommitDiffRequest) returns (stream CommitDiffResponse);
message CommitDiffRequest {
enum DiffMode {
DEFAULT = 0;
WORDDIFF = 1;
}
enum WhitespaceChanges {
WHITESPACE_CHANGES_UNSPECIFIED = 0;
WHITESPACE_CHANGES_IGNORE = 1;
WHITESPACE_CHANGES_IGNORE_ALL = 2;
}
Repository repository = 1;
// Left commit (e.g. merge base SHA).
string left_commit_id = 2;
// Right commit (e.g. workspace HEAD SHA).
string right_commit_id = 3;
repeated bytes paths = 5;
bool collapse_diffs = 6;
bool enforce_limits = 7;
int32 max_files = 8;
int32 max_lines = 9;
int32 max_bytes = 10;
int32 safe_max_files = 11;
int32 safe_max_lines = 12;
int32 safe_max_bytes = 13;
int32 max_patch_bytes = 14;
DiffMode diff_mode = 15;
map<string, int32> max_patch_bytes_for_file_extension = 16;
WhitespaceChanges whitespace_changes = 17;
bool collect_all_paths = 18;
}
message CommitDiffResponse {
bytes from_path = 1;
bytes to_path = 2;
string from_id = 3;
string to_id = 4;
int32 old_mode = 5;
int32 new_mode = 6;
bool binary = 7;
bytes raw_patch_data = 9;
bool end_of_patch = 10;
bool overflow_marker = 11;
bool collapsed = 12;
bool too_large = 13;
int32 lines_added = 14;
int32 lines_removed = 15;
}Also used: FindChangedPaths for identifying which
paths diverge between integration branch and workspace:
rpc FindChangedPaths(FindChangedPathsRequest) returns (stream FindChangedPathsResponse);
message FindChangedPathsRequest {
message Request {
message TreeRequest {
string left_tree_revision = 1;
string right_tree_revision = 2;
}
message CommitRequest {
string commit_revision = 1;
repeated string parent_commit_revisions = 2;
}
oneof type {
TreeRequest tree_request = 1;
CommitRequest commit_request = 2;
}
}
Repository repository = 1;
repeated Request requests = 3;
// ...
}
message FindChangedPathsResponse {
repeated ChangedPaths paths = 1;
}
message ChangedPaths {
enum Status {
ADDED = 0;
MODIFIED = 1;
DELETED = 2;
TYPE_CHANGE = 3;
COPIED = 4;
RENAMED = 5;
}
bytes path = 1;
Status status = 2;
int32 old_mode = 3;
int32 new_mode = 4;
string old_blob_id = 5;
string new_blob_id = 6;
bytes old_path = 7;
int32 score = 8;
string commit_id = 9;
}7.9
CommitService.FindCommit -- Resolve HEAD
Used in: E04-01 (resolve baseline), E04-05 (get current HEAD)
// commit.proto
rpc FindCommit(FindCommitRequest) returns (FindCommitResponse);
message FindCommitRequest {
Repository repository = 1;
// Any commitish: SHA, branch name, tag, "refs/heads/<integration_branch>", etc.
bytes revision = 2;
bool trailers = 3;
}
message FindCommitResponse {
// Nil if not found.
GitCommit commit = 1;
}Conman usage:
async fn resolve_commit(
client: &GitalyClient,
repo: &gitaly::Repository,
revision: &str,
) -> Result<String, ConmanError> {
let resp = client
.commit_service()
.find_commit(FindCommitRequest {
repository: Some(repo.clone()),
revision: revision.as_bytes().to_vec(),
trailers: false,
})
.await?
.into_inner();
resp.commit
.map(|c| c.id)
.ok_or(ConmanError::NotFound {
entity: "commit",
id: revision.to_string(),
})
}7.10
CommitService.CommitIsAncestor -- Up-to-date
check
Used in: E04-05 (sync-integration short-circuit)
// commit.proto
rpc CommitIsAncestor(CommitIsAncestorRequest) returns (CommitIsAncestorResponse);
message CommitIsAncestorRequest {
Repository repository = 1;
string ancestor_id = 2;
string child_id = 3;
}
message CommitIsAncestorResponse {
bool value = 1;
}8. Implementation Checklist
Ordered for incremental implementation. Each step should result in a passing test suite before moving to the next.
9. Test Cases
9.1 Unit tests
(conman-core)
| # | Test | Assertion |
|---|---|---|
| U1 | validate_path_not_blocked(".git/config", DEFAULT_BLOCKED) |
Returns Err(Validation) |
| U2 | validate_path_not_blocked(".github/workflows/ci.yml", DEFAULT_BLOCKED) |
Returns Err(Validation) |
| U3 | validate_path_not_blocked(".gitignore", DEFAULT_BLOCKED) |
Returns Err(Validation) |
| U4 | validate_path_not_blocked("config/app.json", DEFAULT_BLOCKED) |
Returns Ok(()) |
| U5 | validate_path_not_blocked("src/.gitkeep", DEFAULT_BLOCKED) |
Returns Ok(()) (only exact
.gitignore blocked, not nested) |
| U6 | validate_file_size(5_242_881, 5_242_880) |
Returns Err(Validation) |
| U7 | validate_file_size(5_242_880, 5_242_880) |
Returns Ok(()) |
| U8 | validate_file_size(0, 5_242_880) |
Returns Ok(()) |
| U9 | Branch name derivation: "Alice@example.com" +
"My App" -> "ws/alice/my-app" |
Correct |
| U10 | Branch name derivation rejects "../evil" in email
prefix |
Returns Err(Validation) |
9.2 Database tests
(conman-db)
| # | Test | Assertion |
|---|---|---|
| D1 | Insert workspace, find by ID | Found with matching fields |
| D2 | Insert two workspaces with same
app_id + branch_name |
Second insert fails (unique index) |
| D3 | Insert two default workspaces for same user + app | Second insert fails (partial unique index) |
| D4 | Insert non-default workspace for same user + app | Succeeds |
| D5 | list_by_app with pagination |
Returns correct page and total count |
| D6 | update_head_sha |
Subsequent find_by_id returns new SHA |
| D7 | find_default(app_id, user_id) |
Returns the is_default: true workspace |
9.3 Git integration
tests (conman-git)
| # | Test | Assertion |
|---|---|---|
| G1 | create_workspace_branch from integration branch
HEAD |
Returns valid commit SHA, branch exists |
| G2 | create_workspace_branch with non-existent start
point |
Returns ConmanError::Git |
| G3 | commit_file CREATE action |
New file appears in tree |
| G4 | commit_file UPDATE action |
File content updated |
| G5 | commit_file DELETE action |
File removed from tree |
| G6 | list_tree_entries on root |
Returns expected files/dirs |
| G7 | list_tree_entries on subdirectory |
Returns entries of that directory only |
| G8 | read_file for existing path |
Returns correct bytes |
| G9 | read_file for non-existent path |
Returns ConmanError::NotFound |
| G10 | rebase_workspace_onto_integration with no
divergence |
Returns clean: true, same SHA |
| G11 | rebase_workspace_onto_integration with clean
rebase |
Returns clean: true, new SHA |
| G12 | rebase_workspace_onto_integration with
conflict |
Returns clean: false, conflicting paths
non-empty |
9.4 API integration
tests (conman-api)
| # | Test | Assertion |
|---|---|---|
| A1 | POST /workspaces without body |
Creates default workspace, returns 201 with
branch ws/<user>/<app> |
| A2 | POST /workspaces twice for same user |
Second call returns 409 Conflict (default already
exists) |
| A3 | POST /workspaces with custom
branch_name |
Creates non-default workspace, returns 201 |
| A4 | GET /workspaces |
Returns paginated list |
| A5 | GET /workspaces/:id |
Returns single workspace |
| A6 | PATCH /workspaces/:id with
{ "title": "My WS" } |
Title updated |
| A7 | GET /workspaces/:id/files?path= (root) |
Returns directory listing |
| A8 | GET /workspaces/:id/files?path=config/app.json |
Returns file content (base64) |
| A9 | PUT /workspaces/:id/files with valid path |
Returns 200 with new commit SHA |
| A10 | PUT /workspaces/:id/files with blocked path
.git/config |
Returns 400 Validation |
| A11 | PUT /workspaces/:id/files with oversized
content |
Returns 400 Validation |
| A12 | DELETE /workspaces/:id/files with valid path |
Returns 200 with new commit SHA |
| A13 | DELETE /workspaces/:id/files with blocked
path |
Returns 400 Validation |
| A14 | POST /workspaces/:id/reset |
HEAD matches baseline, returns 200 |
| A15 | POST /workspaces/:id/sync-integration
(clean) |
Returns 200 with clean: true |
| A16 | POST /workspaces/:id/sync-integration
(conflict) |
Returns 200 with clean: false and
conflicting paths |
| A17 | POST /workspaces/:id/checkpoints |
Returns 200 with commit SHA |
| A18 | Write to another user's workspace as
Role::User |
Returns 403 Forbidden |
| A19 | Write to another user's workspace as
Role::AppAdmin |
Returns 200 (admin override) |
| A20 | Unauthenticated request to any endpoint | Returns 401 |
10. Acceptance Criteria
Default workspace auto-creation: A user's first interaction with
POST /workspacesfor an app creates a branchws/<email_prefix>/<app_name>from the app baseline. The MongoDB document recordsis_default: trueand the correcthead_sha.Full file editing except blocked paths: Users can create, update, and delete files at any path not matched by the app's
blocked_pathsglobs. Attempts to write to blocked paths return400.File size guardrail: Files exceeding
app.file_size_limit_bytes(default 5 MB) are rejected with400.File tree browsing:
GET .../files?path=returns directory listings (with type, OID) when path is a directory, and base64 content when path is a file.Workspace reset:
POST .../resetpoints the workspace branch back to the app baseline commit. Thehead_shain MongoDB matches the baseline.Deterministic sync-integration:
POST .../sync-integrationrebases the workspace onto currentintegration_branch. On success, returnsclean: trueand updatedhead_sha. On conflict, returnsclean: falsewith the list of conflicting file paths. The workspace branch is not corrupted on conflict.Multi-workspace API readiness:
POST /workspacesaccepts an optionalbranch_nameto create additional workspaces.GET /workspacespaginates across all workspaces in an app.Checkpoint support:
POST .../checkpointscreates a commit on the workspace branch for apps usingmanual_checkpointmode.Audit trail: Every mutation (create workspace, write file, delete file, reset, sync, checkpoint) emits an audit event with actor, entity, before/after state, and git SHA.
Ownership enforcement: Non-admin users cannot mutate another user's workspace.
app_admincan operate on any workspace in their app.