Server-Side Draft Commit & Unnamed Branch Workflow (JJ-like) #3103
Zak Siddiqui opened 16 hours ago

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:

  1. Premature commit messages: Developers often write meaningless placeholder messages ("wip", "fix", "asdf") because they don't yet know what the commit should say (when they later want to squash and/or rename commits). This creates noise in the history that must later be rewritten, which is often never done.
  2. Premature branch naming: Developers often create branches with names like fix-something-v2-final-really because requirements change during development. Renaming branches mid-work is cumbersome in standard Git.
  3. Cognitive overhead: The requirement to name every commit and branch interrupts flow state. Developers must context-switch from coding to describing, which reduces productivity.
  4. Selective staging workflow friction: Git's interactive rebase and selective staging are powerful but complex. A simpler UI-based approach to creating commits from specific hunks/files would lower the barrier.

How This Issue Mitigates the Problems

  1. No more meaningless commit messages: Commits auto-named with timestamps (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.
  2. No more issue or branch naming anxiety: Branches auto-named with timestamps (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.
  3. Reduced cognitive overhead: Developers can focus entirely on code changes without interruption. All naming/describing tasks are deferred to a single cleanup session before creating a merge request.
  4. Simplified selective commits: A UI-based staged diff editor in OneDev allows hunk/file selection per commit, making it easy to create focused commits from mixed changes without command-line expertise.

Risks & Preemptive Justifications

RiskMitigation
"This encourages messy history"The workflow explicitly requires cleanup before merging. A merge request cannot be created if the source branch is unnamed. Merging is disallowed if unnamed commits exist. This forces discipline before code enters main branches, not during development.
"Timestamps are ugly"Timestamps are intentionally machine-readable placeholders. They are never visible in the final history because they must be renamed/described before merging. They serve only as temporary identifiers during the draft phase.
"This duplicates JJ functionality"This is not a client-side tool. It is a server-side UI that makes unnamed commits/branches accessible to developers who use standard Git clients or the OneDev web UI. JJ users continue using JJ unchanged. This extends the same flexibility to all OneDev users regardless of local tooling.
"Unnamed branches pollute the ref namespace"All unnamed branches use a predictable prefix (unnamed/), making them easy to filter, garbage-collect, or ignore. OneDev already has mechanisms for managing refs.
"Developers might never name their commits"The merge block is the forcing function. You cannot complete the workflow without naming everything. This is identical to how JJ requires cleanup before pushing.
"Other developers see draft work in Git"Unnamed commits and branches are real Git objects, so they are technically visible to anyone who lists remote refs. However, they are prefixed with 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

  • Backend: REST API endpoints for managing unnamed commits and branches.
  • Frontend: Vue.js components rendered in the OneDev web UI.
  • Storage: Standard Git refs with synthetic commit messages.

Core Concepts

1. Unnamed Commits

  • Storage: Real Git commits with message format unnamed/2024-01-15T14:30:22.
  • Author identification: Determined by the Git commit author field.
  • Visibility in UI (for author): Shown with a faded/dimmed visual style indicating draft/privacy status. Clicking the commit opens a modal with a multiline text input to type a new commit message (similar to existing OneDev commit editing).
  • Visibility in UI (for non-authors): Completely hidden.
  • Visibility via Git: Visible to anyone listing remote commits (unavoidable since they are real Git objects).
  • Cleanup: Commits become "named" once the user provides a real message via the modal. They are no longer treated as drafts.

2. Unnamed Branches

  • Storage: Real Git branches with name format unnamed/2024-01-15T14:30:22.
  • Author identification: Determined by the Git commit author field of the branch tip (or stored separately if the branch has no commits yet).
  • Visibility in UI (for author): Shown with a faded/dimmed visual style. Clicking the branch name opens an inline text input field to type a new name.
  • Visibility in UI (for non-authors): Completely hidden.
  • Visibility via Git: Visible to anyone listing remote branches (unavoidable since they are real Git refs).
  • Cleanup: Branches become "named" once the user provides a real name via inline edit. They are no longer treated as drafts.

3. Mixed State Support

Users can freely mix and match unnamed/named states:

Branch NameCommit MessagesState
NamedNamedReady for merge request
NamedUnnamedBranch is named but commits are drafts
UnnamedNamedBranch is draft but commits are described
UnnamedUnnamedFull draft mode

4. Merge Request Restrictions

  • Branch-level: The "Create Merge Request" button is disabled in the UI for unnamed branches. API-based creation is still allowed (primarily for server-side edits).
  • Commit-level: Merging is disallowed if any commits in the branch are still unnamed.
  • Cleanup requirement: Before merging, every commit must be described and the branch must be renamed from its 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.

  • Single draft branch: The section is rendered expanded (not collapsed) to prominently reward correct usage of the draft workflow. The single unnamed branch is immediately visible.
  • Multiple draft branches (>1): The section is rendered collapsed by default, hiding the concern from users who may be overusing the feature (and therefore discouraging misuse). A count badge (e.g., "3 drafts") is shown on the collapsed header.
  • Empty state (0 drafts): The section is not shown at all.

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:

  • Faded/dimmed text to indicate draft status.
  • "(Draft)" label appended after the branch name (e.g., unnamed/2024-01-15T14:30:22 (Draft)).
  • Reuses the same visual pattern already existing in OneDev for indicating "editing" or "pending" states on titles, ensuring consistency.

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:

  1. User is on the page showing the diff of the current unnamed commit.
  2. User clicks "+" on a hunk (or selects a file) to stage it for extraction.
  3. A new unnamed commit is created before the current unnamed commit, containing only the selected hunks/files.
  4. The current unnamed commit now contains only the remaining hunks.

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:

  • A modal or inline prompt appears requiring the user to enter a commit message for the new preceding commit.
  • The split does not complete until a non-empty commit message is provided.
  • The current (remainder) commit remains unnamed and can be named later.

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:

  • Clicking "Save as Draft" creates an unnamed commit on the unnamed branch (auto-named with timestamp as per the unnamed commit format) without requiring any commit message input from the user.
  • The user's changes are immediately saved as a draft commit, preserving their work without interrupting flow.
  • The unnamed commit can later be named/described when the user is ready.

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)

  1. BranchesPage.vue – Add "Draft Branches" collapsible section at top. Logic for single vs. multiple drafts.
  2. BranchSelector.vue – Add faded/dimmed styling and "(Draft)" label for unnamed branches in dropdowns.
  3. CommitDiffView.vue – Add "+" icon per hunk and file-level selection controls. Integrate split operation modal.
  4. CommitSplitModal.vue – New component. Forced naming prompt for the preceding commit. Validates non-empty input before proceeding.
  5. FileEditor.vue – Add "You have uncommitted changes" indicator + "Save as Draft" button.
  6. CommitTitleEditor.vue – Reuse existing inline-editing pattern (question 3) for both branch renaming and commit message editing.

Backend API Endpoints

  1. 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.
  2. POST /projects/{projectId}/branches/{branchName}/save-draft – Creates an unnamed commit from uncommitted editor changes.
  3. PUT /projects/{projectId}/commits/{commitId}/rename – Renames an unnamed commit with a user-provided message.

Security & Authorization

  • Only the author of an unnamed commit/branch can rename it or see it in the UI.
  • Non-authors see unnamed branches and commits as if they do not exist.
  • API endpoints enforce author identification via the authenticated user.
  • Zak Siddiqui commented 15 hours ago

    @robin I've added implementation notes

  • Zak Siddiqui commented 15 hours ago

    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.

  • Zak Siddiqui commented 13 hours ago

    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.

  • Zak Siddiqui commented 10 hours ago

    @robin here is the implementation plan generated by service desk.

    Prompt:

    Implementation Plan

    Design Principles

    1. Zero database changes — All state is derived from Git objects themselves (commit messages and ref names). Author identification uses the Git commit author field.
    2. Prefix conventionunnamed/ prefix on both commit messages and branch names is the sole detection mechanism. No database table or cache of "namedness."
    3. 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.
    4. In-place branch rename — Standard git branch -m for renaming branches. The old unnamed/ ref is deleted.
    5. 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.
    6. 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}/rename

    Request body:

    {
      "newMessage": "Fix the widget rendering"
    }
    

    Logic:

    1. Resolve commitId to a RevCommit.
    2. Verify the commit message starts with unnamed/ — if not, return 400.
    3. Verify the authenticated user's email matches the commit author's email — if not, return 403.
    4. Load the branch(es) that point to this commit (or use branch name from query param).
    5. Perform git commit --amend via JGit:
      • Create a new commit with same tree, same parent(s), but new message.
      • Update the branch ref to point to the new commit.
    6. 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.vue

    Location: 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 the DraftUtil equivalent 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 /rename endpoint.
    • 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 BranchService or GitUtils:

    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 draftBranches array alongside namedBranches, or add a query parameter ?type=all|named|draft.

    3B. Frontend: BranchesPage.vue Modification

    Current 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 === 1expanded = true (section open by default)
    • draftBranches.length > 1expanded = false (section collapsed, badge shown)
    • draftBranches.length === 0 → section not rendered at all

    3C. Frontend: BranchSelector.vue Modification

    For 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-commit

    Request 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:

    1. Parse the current unnamed tip commit.
    2. Get its diff against its parent.
    3. From the selected hunks/files, reconstruct two diffs:
      • Selected diff: contains only the selected hunks + entire selected files.
      • Remainder diff: contains everything else.
    4. Create a new named commit (using newCommitMessage) by applying the selected diff to the parent tree.
      • This commit becomes the preceding commit.
    5. Create a new unnamed commit by applying the remainder diff on top of the preceding commit.
      • This becomes the new tip.
    6. Update the branch ref to point to the new unnamed tip.
    7. 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 2
    

    5B. Frontend: CommitDiffView.vue Modification

    Current 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.vue

    Behavior:

    • 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-commit endpoint.
    • 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 NameCommits"Create PR" allowed?Merge allowed?
    NamedNamed✅ Yes✅ Yes
    NamedUnnamed✅ Yes (branch is named)❌ No (unnamed commits)
    UnnamedNamed❌ No (unnamed branch)❌ No (unnamed branch + commits optional)
    UnnamedUnnamed❌ 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

    FilePhaseDescription
    src/main/java/io/onedev/server/git/DraftUtil.java1Core detection & generation utilities
    src/main/java/io/onedev/server/rest/DraftResource.java2,4,5REST endpoints for draft operations
    src/main/java/io/onedev/server/web/component/draft/CommitSplitModal.java5Wicket modal component for split commit
    src/main/resources/javascript/draft/DraftUtil.js1JS counterpart for client-side detection
    src/main/resources/ui/draft/CommitSplitModal.vue5Vue.js modal for split commit naming
    src/test/java/io/onedev/server/git/DraftUtilTest.java1Unit tests

    Modified Files

    FilePhaseDescription
    Existing branch list endpoint3Add draft branch filtering
    BranchesPage.java / BranchesPage.vue3Draft Branches collapsible section
    BranchSelector.java / BranchSelector.vue3Faded/dimmed draft styling
    CommitDiffView.java / CommitDiffView.vue5+ staging icon per hunk, file checkboxes
    FileEditor.java / FileEditor.vue4"Save as Draft" button + indicator
    Commit modal component4"Save as Draft" checkbox
    PullRequestService.java6Merge request restrictions
    Merge request creation UI6Disabled button + tooltip
    Merge request detail page6Merge-blocked banner

    Testing Plan

    Unit Tests (all phases)

    • DraftUtilTest: prefix matching, timestamp generation, author matching
    • DraftResourceTest: rename endpoint (success, 400, 403), save-draft endpoint, split-commit endpoint
    • PullRequestServiceTest: 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

    RiskMitigation
    Orphaned commits accumulating in repoGit GC handles automatically; info toast educates users
    Non-author creates unnamed commit via CLI with someone else's identityAuthor field mismatch means commit is invisible in UI to both users; acceptable per spec
    Branch name with colons rejected by GitSwap 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 editsLock the branch during split operation (same as other Git write operations in OneDev)
    Performance: scanning all commits for unnamed detectionOnly 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.

1/1
Type
New Feature
Priority
Normal
Assignee
Labels
No labels
Issue Votes (0)
Watchers (2)
Reference
OD-3103
Please wait...
Connection lost or session expired, reload to recover
Page is in error, reload to recover