Android: Resolve Merge Conflicts #337
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Plan: Android — merge on pull, stream check card, conflict resolver
The design reference is the handover attached to this issue: 337-handover.tar.gz (
plan/SPEC.md,plan/screens/*.png,plan/mockup/changes-screen-mockup.html). ReadSPEC.mdand look at the screenshots first. This plan maps it onto the code, and it wins where the two differ. The places where it differs are listed under Decisions.Decisions (agreed during refinement)
"Can't read file" lists the problems the core really has. The core never fails to parse a note:
unreadablemeans only an I/O or encoding failure, and an unclosed code fence is valid Markdown. So the group lists:fileProblems(an undated file, an unconfigured file type),There is no code-fence lint and no quick fixes. The editor banner shows the message and "Go to line N".
A pull with uncommitted changes commits them first. If an author is set, the open changes are committed with the
commitSummarymessage, then the merge runs. Without an author nothing is touched, and the pull says it needs an author first.Automatic merging only goes as far as a clean merge. The background pull of the reminder worker, and a rejected auto-push, merge and push again only when the merge is clean. On a conflict they abort without changing anything and show a warning: a banner on the Stream and Changes tabs, plus the amber dot. The conflict state only ever comes from a pull the user starts. The background never commits for the user: with uncommitted files it only fast-forwards, and warns if a merge would be needed.
The hunk logic lives in the Rust core, on plain text, and is bound through
streamd-ffi. JGit only merges, writes the markers and stages files.1. Core (
crates/streamd-core)New module
document/conflicts.rs(pure, no I/O):conflicts(text) -> Option<Vec<ConflictHunk>>: each hunk has itsindex, itsstart_line(the<<<<<<<line, 1-based), theoursandtheirslines, andheading, the nearest Markdown heading above it (## 16:30), if there is one. A diff3 base section (|||||||) is skipped. ReturnsNonewhen the markers are malformed (unbalanced, nested); the resolver then offers only "Edit by hand".enum ConflictChoice { Ours, Theirs, Both }.Bothputs ours first.resolve_conflicts(text, choices: &[ConflictChoice]) -> Result<String, CoreError>: replaces every hunk by its choice and keeps everything else byte for byte, line endings included. A count that doesn't match is an error.conflict_markers(text) -> Vec<usize>: the lines of every marker line (<<<<<<<,=======,>>>>>>>,|||||||). Only lines that are part of a conflict block count, so that a lone=======, such as a setext heading underline, is no marker.Stream check,
api::stream_check(project, store) -> StreamCheck. It reads every file once and returns:notes: the number of listed files,tags: the number of configured tags,unreadable: each file with its message,misnamed: each file with itsfileProblems,marked: each file with its marker lines (the first line is what a row shows),unplaced: each tag with its file count and its first file, fromunplaced_tags.It reuses
unplaced_tags_withandfile_problems, so there is no second wording.document/problems.rsgets the message of a file with leftover markers.Tests: parsing (simple, two hunks, diff3, CRLF, malformed, a lone
=======),resolve_conflictsfor every choice, marker lines, andstream_checkon aMemoryStore(withwith_unreadable).2. Bindings (
crates/streamd-ffi)ConflictHunkandStreamCheckwith its row records. The enumConflictChoice.conflicts,resolveConflictsandconflictMarkers, which need no project, andproject.streamCheck(store).u32. Conversion tests, as for the other records.3. Git layer (
android/.../git/)GitRepository/JGitRepository:pull(remote)merges in place of fast-forward only:setFastForward(FF), with the messageMerge origin/<branch>and the author of the settings, whichpullnow takes.PullbecomesUP_TO_DATE,UPDATED(a fast-forward),MERGED(a clean merge commit),CONFLICTEDandDIVERGED.DIVERGEDis kept for "a merge is needed but was not allowed", which a pull withallowMerge = false(the background) returns.JGitRepositoryrewrites the labels of the markers in each conflicted file once: JGit writesHEAD/refs/remotes/origin/<branch>, the files get<<<<<<< this phone/>>>>>>> origin/<branch>. Only the label of those two marker lines changes.abortMerge(): a hard reset toHEAD, then clearMERGE_HEADandMERGE_MSG.merge(): MergeState?is null when not merging. Otherwise it has the conflicted files: the path, the hunk count (the marker blocks in the working file), whether the file is resolved (staged, no longer instatus().conflicting), and the commit time of the last commit that touched the path on each side (HEADandMERGE_HEAD). The merge state survives an app restart because it is on disk.markResolved(path):git addthe path.finishMerge(author): commits the index as the merge commit withMERGE_MSG.RepositoryStatusgetsmerging: MergeState?, read instatus(), so the existing flows carry it.RepositoryConnection:pull()(from the user): with uncommitted files, commit them first (decision 2;PullState.NeedsAuthorwhen no author is set). Then merge.MERGEDpushes ifpushAfterCommitis on.CONFLICTEDleaves the repository merging and keepspulledAt. The stream reloads onUPDATED,MERGEDandCONFLICTED, since the files changed.backgroundPull()fornotify/Reminders.kt: with a clean tree it merges and pushes. On a conflict it aborts at once and setsPullState.NeedsYou. With a dirty tree it only fast-forwards, andDIVERGEDsetsNeedsYoutoo.commitSaved: returns at once while merging, so auto-commit is paused. When its push isREJECTED, it pulls like the background does (clean merge and push again, else abort andNeedsYou).written(id)while merging: if the file is conflicted and has no markers left, it is staged.StreamAccessasks the session forconflictMarkersof the written text and callsrepository.resolved(id). The marker check stays in the core.abortMerge()(SyncState.Aborted),resolve(path, text)(writes and stages),conflictText(path)(reads a file of the repository, also outside the notes folder), andfinishMerge(). Finishing commits with the author, pushes if the toggle is on (SyncState.Merged(pushed)), then commits any saves made during the merge as an ordinary commit when every save is committed.RepositoryActions/ChangesActionsgetabortMerge,finishMergeand the resolver calls.RepositoryAdapterimplements them and reloads the stream after an abort, a resolve or a finish.4. Stream check card (spec §1)
StreamHealthis replaced by a plainStreamCheck: the counts and the rows of each group, andcheckedAt.StreamSession.check()bindsproject.streamCheck(store)onDispatchers.IOand keeps the last result for the badge.StreamVersionchanges (after a pull, a merge or a resolve, and on coming back from the editor or the resolver). It shows the "Checking" state only after 150 ms without a result.StatusCardloses the green sentence. A newStreamCheckCard(inui/Changes.kt, orui/StreamCheck.ktif it grows) follows the mockup:N notes · M tags.positivetint and border, a plain tick,… · checked on open, HH:mm.warningtint, orerrorwhen a file can't be read or has markers. The groups are CAN'T READ FILE (unreadable, markers, file-name problems; trailing "Open") and NOT DEFINED IN .STREAMD.TOML (@Tag used in N notes, the first file+N; trailing "Define"). Errors come first.StreamdTheme.colorsand the colour scheme, never from the mockup's hex values. New icons (tick, tag, file, merge, check-circle) are Material Symbols vector drawables, asIcons.ktdescribes.changesBadge→changesDot) when there are uncommitted files, unpushed commits, a merge, aNeedsYoupull, or problems in the last check.5. Clickable problems (spec §2)
ConfigRequestgets aprefillthatConfigEditScreenputs into the name of a new tag; the dimension starts attagStart. Back returns to Changes. This builds on the existing forms; there is no new Tags screen.CaptureRequest(file, line)), which selects the failing line on opening. A banner above the text gives the message,line N · Streamd can't read this file(or the wording of the problem), and "Go to line N". The editor has no gutter, so the marked line gets a red tint through the existingVisualTransformation, not a gutter number.conflictMarkersandfileProblems. When it passes, the banner turns green: "File reads cleanly · save to clear the problem".6. Merge conflicts (spec §3, §4)
Changes screen while
status.merging != null:· merging. The pull icon, the pull to refresh and the "Pull" menu item are disabled.pull stopped HH:mm · N conflicts in M files, and the red note, worded with "another device", since the app doesn't know the device.X of Y resolved) replaces the commit section. It has a row per file (red merge icon or green check, the path in monospace,N conflicts/resolved, a chevron) that opens the resolver.NeedsYoupull shows a warning banner ("origin/ and this phone changed the same lines. Pull here to resolve them.") with a Pull action, on Changes and on the Stream tab, where the banner of a failed pull is.The resolver,
ui/Conflict.kt, is a new destination without the bottom bar (ConflictRequest(path).route):ConflictScreenloadsconflictTextand the hunks.ConflictContenttakes the hunks, the choices and the callbacks, for the screenshot tests.rememberSaveable. The bottom bar has "Edit by hand" (outlined) and "Mark resolved", which is enabled when every hunk has a choice. It runsresolveConflicts, thenresolve(path, text), and goes back.conflictsgivesNone) show only "Edit by hand".· merging. Saving with no markers left stages the file throughwritten(§3).Resolvingand its plain types (Hunk,Choice,ConflictFile) live ingit/, besideRepositoryActions. AConflictsAdaptercombinesRepositoryConnection(read, write, stage) withStreamSession(conflicts,resolveConflicts,conflictMarkers, the only calls to the bindings).7. Tests
JGitRepositoryon a bare repository in a temporary folder: a fast-forward, a clean merge (MERGED, two parents), a conflict (CONFLICTED, relabelled markers,merge()lists the file),markResolved+finishMerge,abortMerge(the working tree andHEADas before the pull), and a background pull with a dirty tree (DIVERGED, nothing touched).RepositoryConnectionon the fakeGitRepository: commit-first on pull,NeedsAuthor, auto-commit paused while merging, a rejected auto-push that merges cleanly or aborts withNeedsYou,writtenstaging a file without markers, and finishing with and without push.changesDot, the grouping and order of the check rows, and the counting ofX of Y resolved.ChangesScreenshotsfor checking, valid, problems, merging (0 of 2), all resolved andNeedsYou.ConflictScreenshotsfor no choice yet, one chosen and malformed.CaptureScreenshotsfor the problem banner, the green banner and the marker banner. Record them withupdateDebugScreenshotTestand look at every image.8. Documentation
git/additions,ui/Conflict.kt,ConflictsAdapter,Resolving,StreamCheckreplacingStreamHealth, and the dot badge.Order of commits (suggested)
feat(core): conflict hunks and the stream check, with the FFI bindings.feat(android): merge on pull, abort and finish a merge: the git layer andRepositoryConnection, with tests.feat(android): stream check card with clickable problems.feat(android): resolve merge conflicts on the phone: the resolver, edit by hand and the merging state of Changes.docs: …, if not folded into the commits above.nix flake checkmust pass, andnix build .#streamd-apptoo (it runs the unit and screenshot tests).Out of scope
As in the spec: choices finer than a whole hunk, a diff viewer change, and the other screens. Also, from the decisions: lints for valid Markdown such as unclosed fences, and quick fixes.
Implementation note
Implemented in #339 (branch
337_merge-conflicts, 6 commits).Findings during the implementation
MERGE_MSG: after a conflict, JGit writes# Conflicts:comment lines intoMERGE_MSG. Unlike the git CLI it doesn't strip them, so they would end up in the commit message. The merge commit therefore takes its message from the stored label (Merge origin/<branch>) instead.MERGING_RESOLVED: once every conflict is staged, JGit reports the repository state asMERGING_RESOLVEDinstead ofMERGING. Both count as a merge that waits..git/STREAMD_CONFLICTSbesideMERGE_HEAD.HEADandrefs/remotes/origin/<branch>. They are rewritten tothis phoneandorigin/<branch>right after the merge. The core's parser accepts any label anyway.setCommit(false)and the commit is made by hand, so it gets the configured author. Otherwise JGit would use the empty user configuration of the app.Not done or different