-
-
I would encourage you to watch the following video on JJ so that the benefits are more apparent
JJ With Git is My New Favorite Workflow
Just to make clear, I'm not actually asking for JJ itself to be implemented in OneDev, but just for the best and most commonly used features to be implemented into the OneDev UI.
-
Description updated, because unnamed/draft branches may be used as merge targets, but a merge request cannot be opened from a draft source – your work must be finalized (renamed/described) before it lands, even if the receiving branch is still a draft. This is deliberate: the target may be a feature branch that still needs fixing, so the cleanliness requirement applies to what you contribute, not to what receives it.
-
@robin here is the implementation plan generated by service desk.
Prompt:
Implementation Plan
Design Principles
- Zero database changes — All state is derived from Git objects themselves (commit messages and ref names). Author identification uses the Git commit author field.
- Prefix convention —
unnamed/prefix on both commit messages and branch names is the sole detection mechanism. No database table or cache of "namedness." - Amend-in-place — Commits are renamed via
git commit --amend(new commit object, old one becomes orphaned). This is safe because draft commits are meaningless placeholders; Git GC will clean up orphaned objects naturally. - In-place branch rename — Standard
git branch -mfor renaming branches. The oldunnamed/ref is deleted. - Author scoping — UI visibility and rename authority are determined by matching the authenticated user against the Git commit author email of the branch tip commit.
- Gradual rollout — The feature is opt-in: developers choose "Save as Draft" explicitly. No automatic creation of unnamed branches/commits unless the user intentionally invokes the draft workflow.
Phase 1: Core Detection Utilities (Backend)
Goal: Create a shared utility class that all other phases depend on.
New Java Class:
DraftUtil(package:io.onedev.server.git)public class DraftUtil { private static final String UNNAMED_PREFIX = "unnamed/"; private static final DateTimeFormatter TIMESTAMP_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss"); public static boolean isUnnamedCommit(Commit commit) { return commit.getMessage() != null && commit.getMessage().startsWith(UNNAMED_PREFIX); } public static boolean isUnnamedBranch(String refName) { return refName.startsWith("refs/heads/" + UNNAMED_PREFIX); } public static String generateUnnamedCommitMessage() { return UNNAMED_PREFIX + ZonedDateTime.now(ZoneOffset.UTC).format(TIMESTAMP_FMT); } public static String generateUnnamedBranchName() { return UNNAMED_PREFIX + ZonedDateTime.now(ZoneOffset.UTC).format(TIMESTAMP_FMT); } public static boolean isAuthor(Commit commit, User user) { return commit.getAuthor().getEmail().equals(user.getEmail()); } }Note on timestamp format: For commit messages, we can use standard ISO with colons (
2024-01-15T14:30:22). For branch names, colons are forbidden in Git refs, so we replace them with hyphens:unnamed/2024-01-15T14-30-22.Tests
DraftUtilTest— Unit tests for prefix matching, generation, author detection.
Current State Diagram (Branches Page)
+---------------------------------------------------+ | Branches Page | | | | Filter: [Search...] | | | | +----------------------------------------------+ | | | Default Branch (main) | | | | Last commit: "Add CI pipeline" | | | +----------------------------------------------+ | | +----------------------------------------------+ | | | feature/user-management | | | | Last commit: "Add user roles" | | | +----------------------------------------------+ | | +----------------------------------------------+ | | | fix/login-bug | | | | Last commit: "Fix NPE in login" | | | +----------------------------------------------+ | | | +---------------------------------------------------+
Phase 2: Commit Rename API & UI (Backend + Frontend)
Goal: Allow the author of an unnamed commit to replace the synthetic message with a real one.
2A. REST Endpoint (Backend)
PUT /~api/projects/{projectId}/commits/{commitId}/renameRequest body:
{ "newMessage": "Fix the widget rendering" }Logic:
- Resolve
commitIdto a RevCommit. - Verify the commit message starts with
unnamed/— if not, return 400. - Verify the authenticated user's email matches the commit author's email — if not, return 403.
- Load the branch(es) that point to this commit (or use branch name from query param).
- Perform
git commit --amendvia JGit:- Create a new commit with same tree, same parent(s), but new message.
- Update the branch ref to point to the new commit.
- Return the new commit SHA and updated branch info.
JGit implementation notes:
try (RevWalk revWalk = new RevWalk(repository)) { RevCommit oldCommit = revWalk.parseCommit(ObjectId.fromString(commitId)); // Create amended commit CommitBuilder builder = new CommitBuilder(); builder.setTreeId(oldCommit.getTree()); builder.setParentIds(oldCommit.getParents()); builder.setAuthor(oldCommit.getAuthorIdent()); builder.setCommitter(new PersonIdent(user.getName(), user.getEmail())); builder.setMessage(newMessage); ObjectId newCommitId = builder.build(repository); // Update ref RefUpdate ru = repository.getRefDatabase().getRef(branchName).update(); ru.setNewObjectId(newCommitId); ru.forceUpdate(); }2B. Frontend Component:
CommitTitleEditor.vueLocation: Replaces the read-only commit message display on the commit detail page and in the branch list rows.
Behavior:
- Detects if commit message starts with
unnamed/using theDraftUtilequivalent on the JS side. - If unnamed: renders the message with faded/dimmed CSS (e.g.,
opacity: 0.5; color: var(--text-muted); a small (Draft) badge next to it). - On click: transforms the text into an inline
<textarea>pre-filled with any previously typed message (empty by default). - On blur or Enter: calls
PUT /renameendpoint. - If the rename fails (author mismatch, etc.), shows an inline error and reverts to the original message.
- If the response indicates the old commit became orphaned, a subtle info toast appears: "Draft replaced. Old commit will be cleaned up by Git GC."
State Transitions (Commit Rename)
Current State: Branch: unnamed/2024-01-15T14-30-22 Commit: [abc123] unnamed/2024-01-15T14:30:22 ↓ (user clicks rename, types "Add user login") Branch: unnamed/2024-01-15T14-30-22 (branch unchanged) Commit: [def456] Add user login (new SHA, same tree) Orphaned: [abc123] (no refs, GC will clean)
Phase 3: Unnamed Branches — UI Visibility & Renaming (Frontend + Backend)
Goal: Display unnamed branches in a separate "Draft Branches" section on the Branches page, and allow inline renaming.
3A. Backend: Branch Filtering Logic
New method in
BranchServiceorGitUtils:public List<Branch> getUnnamedBranches(Project project, User user) { List<Branch> allBranches = getBranches(project); return allBranches.stream() .filter(b -> DraftUtil.isUnnamedBranch(b.getRefName())) .filter(b -> { Commit tip = b.getTipCommit(); return tip != null && DraftUtil.isAuthor(tip, user); }) .collect(Collectors.toList()); }New REST endpoint or existing branch list enhancement: Modify the existing branches list endpoint to include a
draftBranchesarray alongsidenamedBranches, or add a query parameter?type=all|named|draft.3B. Frontend:
BranchesPage.vueModificationCurrent layout (simplified):
<div class="branches-page"> <div class="branch-list"> <branch-row v-for="branch in branches" :key="branch.name" /> </div> </div>New layout:
<div class="branches-page"> <div v-if="draftBranches.length > 0" class="draft-branches-section"> <div class="section-header" :class="{ collapsed: draftBranches.length > 1 }" @click="toggleDraftSection"> <span class="section-title">Draft Branches</span> <span v-if="draftBranches.length > 1" class="badge"> {{ draftBranches.length }} drafts </span> <span class="collapse-icon">{{ expanded ? '▼' : '▶' }}</span> </div> <div v-show="expanded || draftBranches.length === 1" class="draft-branch-rows"> <branch-row v-for="branch in draftBranches" :key="branch.name" :branch="branch" :is-draft="true" /> </div> </div> <div class="named-branches-section"> <div class="section-header">Branches</div> <div class="branch-list"> <branch-row v-for="branch in namedBranches" :key="branch.name" /> </div> </div> </div>Collapse logic:
draftBranches.length === 1→expanded = true(section open by default)draftBranches.length > 1→expanded = false(section collapsed, badge shown)draftBranches.length === 0→ section not rendered at all
3C. Frontend:
BranchSelector.vueModificationFor all branch selector dropdowns (branch switcher in header, merge request source/target, compare branches):
<select-option v-for="branch in branches" :key="branch.name" :class="{ 'is-draft': branch.isDraft }"> <span class="branch-name">{{ branch.name }}</span> <span v-if="branch.isDraft" class="draft-label">(Draft)</span> </select-option>CSS:
.is-draft { opacity: 0.6; }3D. Frontend: Inline Branch Renaming
Add an inline edit feature to the branch row (and the branch detail page header):
- Detection: branch name starts with
unnamed/. - Visual: The name is rendered with faded/dimmed style + (Draft) badge.
- Interaction: Clicking the name switches to an inline
<input>field. - On submit: Call existing branch rename REST API (
POST /~api/projects/{projectId}/branches/{oldName}/rename) with the new name. - Validation: The new name must not start with
unnamed/(no double-draft). If it does, show inline error "Cannot rename to another draft name."
Proposed State Diagram (Branches Page after Phase 3)
+----------------------------------------------------+ | Branches Page | | | | +--- Draft Branches (collapsed: 3 drafts) ------+ | | | ▶ 3 drafts | | | +-----------------------------------------------+ | | | | +-----------------------------------------------+ | | | Default Branch (main) | | | | [abc] Add CI pipeline | | | +-----------------------------------------------+ | | +-----------------------------------------------+ | | | feature/user-management | | | | [def] Add user roles | | | +-----------------------------------------------+ | | | +----------------------------------------------------+When expanded:
+---------------------------------------------------+ | Branches Page | | | | +--- Draft Branches ---------------------------+ | | | ▼ 3 drafts | | | | +------------------------------------------+ | | | | | unnamed/2024-01-15T14-30-22 (Draft) | | | | | | [abc123] unnamed/2024-01-15T14:30:22 | | | | | +------------------------------------------+ | | | | +------------------------------------------+ | | | | | unnamed/2024-01-17T09-15-33 (Draft) | | | | | | [def456] unnamed/2024-01-17T09:15:33 | | | | | +------------------------------------------+ | | | | +------------------------------------------+ | | | | | unnamed/2024-01-18T11-22-44 (Draft) | | | | | | [ghi789] unnamed/2024-01-18T11:22:44 | | | | | +------------------------------------------+ | | | +----------------------------------------------+ | | | | +----------------------------------------------+ | | | Default Branch (main) | | | | [abc] Add CI pipeline | | | +----------------------------------------------+ | | | +---------------------------------------------------+
Phase 4: "Save as Draft" in File Editor & Commit Modal (Frontend + Backend)
Goal: Allow users to save in-progress changes as unnamed commits without providing a commit message.
4A. Commit Modal Enhancement
Current behavior: When committing changes via the web UI, there's a modal with a commit message text input and a commit button.
Change: Add a "Save as Draft" checkbox below the commit message input.
Behavior specification (per answer 4):
- The checkbox is labeled "Save as Draft".
- When the checkbox is checked, the commit message text area becomes disabled (read-only). The placeholder text
unnamed/2024-01-15T14:30:22(auto-generated timestamp) is used as the commit message. - If the user had already typed a message before checking the checkbox, that typed message is stored in a hidden buffer.
- If the user unchecks the checkbox, the text area is re-enabled and the previously typed message is restored from the hidden buffer.
- The commit button text changes to "Commit as Draft" when checkbox is checked.
<div class="commit-modal"> <textarea v-model="commitMessage" :disabled="isDraft" :placeholder="isDraft ? generatedDraftMsg : 'Describe your changes...'" /> <label class="draft-checkbox"> <input type="checkbox" v-model="isDraft" @change="onDraftToggle" /> Save as Draft </label> <!-- hidden buffer --> <input type="hidden" v-model="savedDraftBuffer" /> <button @click="submitCommit"> {{ isDraft ? 'Commit as Draft' : 'Commit' }} </button> </div>// In the component script data() { return { commitMessage: '', isDraft: false, savedDraftBuffer: '', generatedDraftMsg: DraftUtil.generateUnnamedMessage(), }; }, methods: { onDraftToggle() { if (this.isDraft) { this.savedDraftBuffer = this.commitMessage; this.commitMessage = ''; } else { this.commitMessage = this.savedDraftBuffer; this.savedDraftBuffer = ''; } }, submitCommit() { const msg = this.isDraft ? this.generatedDraftMsg : this.commitMessage; // call existing commit API with msg } }4B. File Editor "Save as Draft" Button
Location: The file editor page (
FileEditor.vue), typically in the toolbar area.Current state: A "Commit Changes" button that opens the commit modal.
Change: Add a secondary "Save as Draft" button right next to (or below) "Commit Changes".
Behavior:
- When clicked, immediately creates an unnamed commit from all current uncommitted changes.
- No modal appears — the commit is created instantly.
- The commit message is auto-generated:
unnamed/2024-01-15T14:30:22. - After saving, a success toast appears: "Changes saved as draft."
- The commit appears in the draft branch's history and in the "Draft Branches" section.
Backend endpoint:
POST /~api/projects/{projectId}/branches/{branchName}/save-draft@POST @Path("/~api/projects/{projectId}/branches/{branchName}/save-draft") public BranchData saveDraft( @PathParam("projectId") Long projectId, @PathParam("branchName") String branchName, @Auth User user) { // 1. Verify the branch is unnamed (or create if it doesn't exist) // 2. Stage all uncommitted changes from the editor's working tree // 3. Create a commit with auto-generated message // 4. Push the commit to the branch // 5. Return updated branch info }4C. "You have uncommitted changes" Indicator
Location: File editor page, persistent banner or snackbar.
<div v-if="hasUncommittedChanges" class="unsaved-indicator"> <span>⚠ You have uncommitted changes</span> <button @click="saveAsDraft">Save as Draft</button> <button @click="openCommitModal">Commit Changes</button> </div>
Phase 5: Commit Splitting — Staging Hunks/Files (Backend + Frontend)
Goal: Allow users to split an unnamed commit by moving selected hunks (or entire files) into a new preceding commit that must be named.
Constraint: This feature is only available for unnamed commits on the current branch's tip (the latest unnamed commit in the draft workflow).
5A. Backend: Split Commit Endpoint
POST /~api/projects/{projectId}/branches/{branchName}/split-commitRequest body:
{ "selectedHunks": [ {"file": "src/main/java/App.java", "hunkIndex": 0}, {"file": "src/main/java/App.java", "hunkIndex": 2} ], "selectedFiles": [ "src/main/java/Utils.java" ], "newCommitMessage": "Refactor user authentication logic" }Logic:
- Parse the current unnamed tip commit.
- Get its diff against its parent.
- From the selected hunks/files, reconstruct two diffs:
- Selected diff: contains only the selected hunks + entire selected files.
- Remainder diff: contains everything else.
- Create a new named commit (using
newCommitMessage) by applying the selected diff to the parent tree.- This commit becomes the preceding commit.
- Create a new unnamed commit by applying the remainder diff on top of the preceding commit.
- This becomes the new tip.
- Update the branch ref to point to the new unnamed tip.
- Return the two new commit SHAs.
Current State Diagram (Before Split)
Parent (named commit) | Current unnamed commit (tip) [abc123] unnamed/2024-01-15T14:30:22 | Diff includes: - App.java hunk 0: change login logic - App.java hunk 1: change validation - App.java hunk 2: add logging - Utils.java: entire file (new helper)Proposed State Diagram (After Split)
Parent (named commit) | New preceding commit (named - forced) [def456] Refactor user authentication logic | Contains: App.java hunk 0 + Utils.java | New unnamed commit (remainder) [ghi789] unnamed/2024-01-15T14:30:22 | Contains: App.java hunk 1 + App.java hunk 25B. Frontend:
CommitDiffView.vueModificationCurrent state: Displays a unified or split diff of the commit, with hunks shown in expandable regions.
Change: When viewing the diff of the current unnamed tip commit, each hunk gains a "+" icon (or a staging checkbox) at the top-left corner. Each file header gains an entire-file checkbox.
<template> <div class="commit-diff"> <file-diff v-for="file in files" :key="file.path"> <template #file-header> <input type="checkbox" v-model="file.selected" @change="onFileSelect(file)" /> <span class="file-path">{{ file.path }}</span> </template> <hunk v-for="(hunk, idx) in file.hunks" :key="idx"> <template #hunk-header> <button v-if="isDraftCommit" class="stage-hunk-btn" @click="stageHunk(file, idx)" title="Stage this hunk into preceding commit"> + </button> <span class="hunk-info">{{ hunk.header }}</span> </template> <diff-line v-for="line in hunk.lines" :key="line.num" /> </hunk> </file-diff> <button v-if="hasSelectedHunks" class="split-commit-btn" @click="openSplitModal"> Split Selected into New Commit </button> </div> </template>5C. New Component:
CommitSplitModal.vueBehavior:
- Activated when user clicks "Split Selected into New Commit".
- Shows a modal with:
- Summary of what's being split (e.g., "3 hunks from 2 files").
- A multiline text input for the new commit message (required, non-empty).
- "Split" button (disabled until a message is entered).
- "Cancel" button.
- On submit, calls
POST /split-commitendpoint. - On success, refreshes the diff view to show the new state.
- On error, shows error message in modal.
<modal :open="show" @close="cancel"> <h3>Split Commit</h3> <p>Extracting {{ selectedHunkCount }} hunks into a new preceding commit.</p> <label>Commit message for the new preceding commit:</label> <textarea v-model="newMessage" rows="4" placeholder="Describe what this commit does..." /> <div class="actions"> <button :disabled="!newMessage.trim()" @click="split">Split</button> <button @click="cancel">Cancel</button> </div> </modal>
Phase 6: Merge Request Restrictions (Backend + Frontend)
Goal: Prevent premature merging of draft work.
6A. Backend Checks
In
PullRequestService(or merge request creation logic):public void checkCanCreatePullRequest(Project project, Branch sourceBranch, User user) { if (DraftUtil.isUnnamedBranch(sourceBranch.getRefName())) { throw new UnnamedBranchException( "Cannot create merge request from an unnamed branch. " + "Please rename the branch first."); } // Additionally check commits for (Commit commit : getCommits(sourceBranch)) { if (DraftUtil.isUnnamedCommit(commit) && DraftUtil.isAuthor(commit, user)) { throw new UnnamedCommitException( "Cannot merge while unnamed commits exist. " + "Please describe all draft commits first."); } } }In merge endpoint (actual merge execution):
Same check to prevent programmatic merges as well.
6B. Frontend: Disable "Create Merge Request" Button
In the branch detail page and the branches list:
<button :disabled="branch.isUnnamed" :title="branch.isUnnamed ? 'Cannot create merge request from a draft branch' : ''" @click="createMergeRequest"> Create Merge Request </button>Add a tooltip explaining why it's disabled and how to fix it ("Rename the branch and describe all commits first").
6C. Merge Check UI Indicator
On the merge request page itself, if the source branch still has unnamed commits:
<div class="merge-blocked-banner"> <span class="icon">🚫</span> <span>Merge blocked: {{ unnamedCommitCount }} unnamed commit(s) remain. Please rename or describe them before merging.</span> <button v-if="canFix" @click="goToBranch(branch.name)"> Review Draft Commits </button> </div>
Phase 7: Integration & Edge Cases (All Layers)
7A. Unnamed Branch with No Commits (Orphan Branch)
If a branch is created with
unnamed/prefix but has no commits yet (empty branch), the author cannot be determined from a tip commit. In this case:- Show the branch in "Draft Branches" only to the user who created it (using the OneDev branch creator metadata if available), or
- Show it to all users but with an "unknown author" indicator.
- Decision per answer 4: Author is determined by the branch creator metadata (OneDev tracks who created each branch internally).
7B. Mixed State Support
The system must handle all four states from the issue:
Branch Name Commits "Create PR" allowed? Merge allowed? Named Named ✅ Yes ✅ Yes Named Unnamed ✅ Yes (branch is named) ❌ No (unnamed commits) Unnamed Named ❌ No (unnamed branch) ❌ No (unnamed branch + commits optional) Unnamed Unnamed ❌ No ❌ No 7C. Orphaned Commit Cleanup
As agreed in answer 2b, old commits become orphaned after rename. No explicit cleanup is needed — Git GC handles this. However, for the user experience, add an informational toast after rename: "Draft replaced. Previous draft will be cleaned up automatically."
7D. Author Scoping Edge Cases
- Commits with multiple authors: Use the committer field (the person who committed) as the "author" for UI visibility, as this is the authenticated user who performed the draft action.
- Rebased branches: If a branch is force-pushed from CLI with unnamed commits, the author field still determines visibility. If a non-author force-pushes an unnamed commit authored by someone else, the commit will be invisible to everyone in the UI (since its author matches neither the pusher nor the current viewer). This is acceptable per the issue's rationale.
Summary: Files to Create / Modify
New Files
File Phase Description src/main/java/io/onedev/server/git/DraftUtil.java1 Core detection & generation utilities src/main/java/io/onedev/server/rest/DraftResource.java2,4,5 REST endpoints for draft operations src/main/java/io/onedev/server/web/component/draft/CommitSplitModal.java5 Wicket modal component for split commit src/main/resources/javascript/draft/DraftUtil.js1 JS counterpart for client-side detection src/main/resources/ui/draft/CommitSplitModal.vue5 Vue.js modal for split commit naming src/test/java/io/onedev/server/git/DraftUtilTest.java1 Unit tests Modified Files
File Phase Description Existing branch list endpoint 3 Add draft branch filtering BranchesPage.java/BranchesPage.vue3 Draft Branches collapsible section BranchSelector.java/BranchSelector.vue3 Faded/dimmed draft styling CommitDiffView.java/CommitDiffView.vue5 + staging icon per hunk, file checkboxes FileEditor.java/FileEditor.vue4 "Save as Draft" button + indicator Commit modal component 4 "Save as Draft" checkbox PullRequestService.java6 Merge request restrictions Merge request creation UI 6 Disabled button + tooltip Merge request detail page 6 Merge-blocked banner
Testing Plan
Unit Tests (all phases)
DraftUtilTest: prefix matching, timestamp generation, author matchingDraftResourceTest: rename endpoint (success, 400, 403), save-draft endpoint, split-commit endpointPullRequestServiceTest: merge request creation blocked for unnamed branches/commits
Integration Tests (all phases)
- E2E: Create unnamed branch via web UI → verify it appears in "Draft Branches"
- E2E: Edit files, "Save as Draft" → verify unnamed commit created
- E2E: Rename unnamed commit → verify new message appears, old commit gone
- E2E: Split commit → verify two commits (named preceding + unnamed tip)
- E2E: Verify "Create Merge Request" button disabled for unnamed source branch
- E2E: Attempt merge with unnamed commits → verify blocked
Visual Regression Tests (frontend)
- Draft Branches section: single draft (expanded), multiple drafts (collapsed), zero drafts (hidden)
- Branch selector dropdowns: unnamed branch shown with dimmed styling
- Commit diff view: + icon appears only for unnamed tip commit
Risk & Mitigation Table
Risk Mitigation Orphaned commits accumulating in repo Git GC handles automatically; info toast educates users Non-author creates unnamed commit via CLI with someone else's identity Author field mismatch means commit is invisible in UI to both users; acceptable per spec Branch name with colons rejected by Git Swap colons for hyphens in branch names (commit messages unaffected) Undo of branch rename (user wants old unnamed name back) No undo needed — user can type a new draft name manually; the old name is gone Split commit operation conflicts with concurrent edits Lock the branch during split operation (same as other Git write operations in OneDev) Performance: scanning all commits for unnamed detection Only scan tip commit for branch; PR checks iterate commits only once per PR creation
This plan covers all five features described in issue #3103 without scope creep, using the agreed design decisions (prefix convention, amend-in-place, inline renaming, draft checkbox in commit modal, no pre-existing UI for drafts). All phases can be implemented in the order shown, with each phase building on the previous one's APIs and utilities.
| Type |
New Feature
|
| Priority |
Normal
|
| Assignee | |
| Labels |
No labels
|
Motivation
Problem Statement
In many Git workflows, developers are forced to commit to naming and describing their work before the work is ready to be shared. Common frustrations include:
fix-something-v2-final-reallybecause requirements change during development. Renaming branches mid-work is cumbersome in standard Git.How This Issue Mitigates the Problems
unnamed/2024-01-15T14:30:22) are honest about their draft status. Developers never need to write "wip" again. They describe commits only when the work is ready.unnamed/2024-01-15T14:30:22) allow developers to start work immediately. Branch naming becomes a deliberate act done when the branch is ready for collaboration, not before.Risks & Preemptive Justifications
unnamed/), making them easy to filter, garbage-collect, or ignore. OneDev already has mechanisms for managing refs.unnamed/and are semantically meaningless to anyone except the author. The OneDev UI hides them completely from non-authors. They carry no misleading information.Implementation Overview
Architecture
Core Concepts
1. Unnamed Commits
unnamed/2024-01-15T14:30:22.2. Unnamed Branches
unnamed/2024-01-15T14:30:22.3. Mixed State Support
Users can freely mix and match unnamed/named states:
4. Merge Request Restrictions
unnamed/prefix.Detailed UI Specifications
1. Unnamed Branches on the Branches Page
A separate "Draft Branches" section is displayed at the top of the branches list, visually above all named branches.
This design intentionally creates positive friction: a single draft is celebrated, but accumulating multiple drafts is hidden behind a click, nudging users to clean up.
2. Branch Selector Dropdowns (Branch Switcher, Merge Request Target, etc.)
Unnamed branches appear in all branch selector dropdowns (branch switcher, merge request source/target, compare branches, etc.) but are visually distinguished as drafts:
unnamed/2024-01-15T14:30:22 (Draft)).This ensures unnamed branches remain accessible for navigation while clearly signaling their draft nature.
3. Staging Hunks/Files into a Preceding Commit (Commit Splitting)
When viewing the diff of the current undescribed commit (the latest unnamed commit on an unnamed branch), each diff hunk displays a "+" icon (or equivalent actionable control). Clicking the "+" icon stages that specific hunk into a new commit inserted before the current undescribed commit, similar to JJ's behavior.
Additionally, users can select an entire file (which includes all hunks within that file) for simplicity. This can be implemented as a checkbox or "select all" control at the file level in the diff view, addressing the concern that per-hunk selection can become tedious with many small changes across many files.
Workflow:
4. Naming the Preceding Commit on Split
When a split is triggered (user stages hunks/files to create a preceding commit), the user is forced to name the preceding commit before the split operation completes:
The rationale: the preceding commit represents work that is "done" and separated from the ongoing draft. It semantically deserves a real description. The remainder commit stays in draft state for continued work.
5. Saving Uncommitted Changes from the File Editor as Draft Commits
Important context: This feature is only for the OneDev web UI (not for pushing from the CLI). Therefore, the implementation focuses on a UI "capture" mechanism.
When a user is editing files in the OneDev web UI and has uncommitted changes, the UI displays a persistent indicator such as: "You have uncommitted changes" accompanied by a prominent "Save as Draft" button.
Behavior:
This eliminates the friction of filing editor-based changes: users never need to write placeholder messages or worry about losing work. They simply click "Save as Draft" and continue.
Implementation Notes for Developers
Frontend Components (New or Modified)
Backend API Endpoints
POST /projects/{projectId}/branches/{branchName}/split-commit– Accepts list of hunks/files to extract into a new preceding commit. Requires a commit message for the new commit. Returns updated branch state.POST /projects/{projectId}/branches/{branchName}/save-draft– Creates an unnamed commit from uncommitted editor changes.PUT /projects/{projectId}/commits/{commitId}/rename– Renames an unnamed commit with a user-provided message.Security & Authorization