Skip to content

Hybrid project scenarios

A hybrid repository is a code repository where Draftline manages one folder, for example contoso-app/cutready/. Business users edit it through a Draftline host. Developers edit the same repository with their own tools and plain Git. Draftline is a guest in that repository: it works the way the GitHub Copilot app does and never takes it over.

Rule What Draftline does
One repository Opening a folder inside a repository joins it. Draftline never creates a nested .git.
One folder Draftline manages one folder: the whole repository or one folder in it. A committed root .draftline file names it. A host with many decks keeps them inside that folder; separate copies use owned worktrees.
Stay in the folder Saves, restores, and shelves read and write only the folder. Merges bring in the whole branch but keep the developer’s index and unrelated local edits.
Never rewrite No squash, cleanup, or force push. Shared history only grows.
Share by pull request Send pushes a branch and opens a pull request. It never pushes to the base branch.
Own names only Draftline creates branches under the host’s namespace (for example cutready/), refs under refs/draftline/, and draftline-* worktrees. It never touches other tools’ refs or worktrees, and never moves or rewinds the base branch.
flowchart LR
    A[Open the folder] --> B[Edit and save]
    B --> C[Send]
    C --> D[Branch pushed, pull request opened]
    D --> E[Reviewed and merged on GitHub]
    E --> F[Get updates from base]
    F --> B
    G[Developer commits with Git] -.-> B

Flow 15: open a folder inside a code repository

Section titled “Flow 15: open a folder inside a code repository”

Business goal: “Open our deck that lives in the app’s repository.”

Question Answer
Covered today? Covered.
Correct primitive path init_with_policy or open_with_policy on the folder, then with_variation_namespace("cutready/"). Tauri hosts set the same namespace once on DraftlineCommandContext::with_variation_namespace. scope() returns the folder; it is empty when Draftline manages the whole repository.
Safety behavior The enclosing repository (or linked worktree) is discovered, never nested. Changes outside the folder are invisible. A folder that resolves outside the repository is rejected. The namespace hides developers’ branches; the base branch and the current branch stay listed. Creating, switching to, renaming, deleting, adopting, restoring onto, or directly publishing a branch outside the namespace fails with VariationOutsideNamespace (variation_outside_namespace, details { "branch": "<branch>" }); switching back to the base is allowed, and project changes are shared through Send. Like git switch, a switch also updates committed code that differs between branches; files with uncommitted changes in the way block it.
Tests init_in_project_folder_joins_enclosing_repo_without_nesting, changes_outside_project_folder_are_invisible, project_folder_in_linked_worktree_scopes_to_worktree, variation_namespace_hides_developer_branches, namespaced_workspace_leaves_developer_branches_alone.

Flow 15a: save and go back next to a developer

Section titled “Flow 15a: save and go back next to a developer”

Business goal: “Save my edits without disturbing the developer’s code.”

Question Answer
Covered today? Covered.
Correct primitive path save_version, restore_version_as_new_save, shelve_changes / apply_shelf as usual.
Safety behavior A save builds its commit from one reading of HEAD plus the folder’s changes, so it never reverts a developer commit that lands mid-save: it saves again on top of that commit. It refuses with LocalStateChanged instead if that commit already holds the edits, or if the branch was switched, reset, or rewound. Whatever the developer staged stays staged and uncommitted. Restores and shelves write only the folder and refuse when an untracked or ignored file is in the way. Files that need a Git filter (for example Git LFS) go through the git executable when the repository configures the driver (Flow 15i); otherwise they are never saved, overwritten, or discarded, and Draftline refuses with a GitFilter hazard before writing anything. Line endings follow core.autocrlf and .gitattributes the same way Git does. When another Git process holds the index or the branch’s ref lock, Draftline waits up to two seconds for it, then refuses with GitIndexLocked (git_index_locked). A lock found before an operation starts changes nothing. A lock that appears mid-operation leaves a recovery record: rollback undoes a restore only for files still at the restored version, keeps a shelf once any of its edits left the disk, and stops a discard where it is. An interrupted switch or restore onto another branch is rolled back or finished across the whole checkout, code included, so HEAD, the index, and the files never disagree; files and staged entries the developer changed meanwhile are left alone. A file the checkout left half-written (missing, or holding only the start of its new content, and not yet staged) is finished or rolled back with the rest, since its content is in a commit; it never reaches the next save. The same holds for a recovery that dies partway. A file deleted after such a crash but before recovery looks the same and is put back, never lost. A save updates the index before it moves the branch, as git commit does, and moves it only if it is still where the save started, so a failed save never leaves a commit that the developer’s next commit would revert; the developer’s staged changes and merge conflicts are put back. libgit2 cannot hold Git’s index lock between reading and writing the index, so a Git command that starts and finishes within that sub-millisecond gap can still race a save.
Tests save_leaves_developer_staged_changes_staged_and_uncommitted, a_restore_rollback_puts_back_a_file_the_checkout_left_missing, a_switch_repair_finishes_a_file_the_checkout_left_missing, a_switch_rollback_rewrites_a_half_written_file, a_switch_repair_finishes_a_file_an_earlier_repair_left_missing, a_switch_repair_keeps_a_file_deleted_after_the_checkout_finished, restore_rolls_back_only_project_content, shelve_and_apply_leave_code_alone, restore_and_switch_never_write_lfs_pointers_over_real_files, discard_and_shelve_never_write_lfs_pointers_over_real_files, switch_refuses_a_branch_whose_own_rules_put_a_file_in_lfs, line_endings_round_trip_like_git_with_autocrlf, save_waits_while_git_holds_the_index, a_save_that_cannot_move_the_branch_leaves_the_index_as_it_was, operations_that_write_the_index_refuse_while_git_holds_it, a_failed_save_keeps_a_developer_merge_conflict, saves_log_the_saver_in_the_reflog_like_git_commit.

Flow 15b: developers commit with plain Git

Section titled “Flow 15b: developers commit with plain Git”

Business goal: “See my teammates’ work as part of the history.”

Question Answer
Covered today? Covered.
Correct primitive path versions, history, and the graph APIs.
Safety behavior history, full_history and the graph show every commit on the branch with its real parents, like git log; each entry’s touches_folder says whether it changes the folder, so a host can dim teammates’ code-only commits. A merge that only takes one side’s folder does not touch it. When Draftline manages the whole repository, every commit touches it. versions lists only commits that touch the folder (like git log -- <folder>), the states a business user can restore. Common ancestors and variation counts use the same full history.
Tests scoped_versions_skip_developer_commits_outside_the_project, scoped_history_lists_teammates_commits_and_flags_project_changes, a_merge_that_only_brings_one_sides_project_changes_does_not_touch_the_project, scoped_graph_ancestor_and_counts_include_teammates_commits.

Business goal: “Share my saves with the team.”

Question Answer
Covered today? Covered. The host supplies a PullRequestProvider (for example GitHub).
Correct primitive path preflight_send(remote, &request, &mut options) to show what will happen, then send(remote, &request, &mut provider, &mut options).
Safety behavior Refuses unsaved changes in the folder. Send shares what is on the branch, like any teammate. On the base branch, or on a branch outside the namespace (a developer’s), it creates a new branch in the namespace at the current commit and switches to it; the branch it left is never pushed or moved, so a base holding saves stays ahead of its upstream until a developer pushes or pulls it. It pushes without force and opens a pull request, or reuses the open one. Retrying after a failure is safe. It pushes exactly the commit it checked. Commits that change files outside the folder (a teammate’s unpushed work, or a hand-made merge resolution) go along; preflight_send lists them in developer_commits so the host can show who else is in the pull request. An explicit request.branch outside the namespace fails with SendOutsideNamespace (send_outside_namespace, details { "branch": "<branch>" }). A provider failure is PullRequest (pull_request_failed); the contract message is the provider’s own text, unprefixed.
Tests send_from_the_default_branch_moves_work_to_a_new_branch_and_opens_a_pull_request, send_retries_safely_after_the_host_fails_and_avoids_taken_branch_names, send_refuses_unsaved_project_changes_before_creating_a_branch, send_carries_a_teammates_unpushed_base_commits_and_leaves_their_base_alone, send_carries_a_variation_started_on_a_teammates_branch, send_from_a_developer_branch_moves_work_to_a_namespaced_branch, send_acts_only_on_the_branch_base_and_commit_the_host_approved, standalone_send_never_flags_developer_commits, send_lists_a_merge_that_resolves_code_outside_the_project.

Business goal: “Bring in my teammates’ and the developers’ latest work.”

Question Answer
Covered today? Covered for clean merges in the folder.
Correct primitive path fetch_remote, then sync_status or base_status. When only behind on the same branch, apply_incoming. Otherwise preflight_merge_incoming (same branch) or preflight_merge_base (base branch), then merge_incoming with the returned token.
Safety behavior The merge covers the whole branch: the folder’s files get Draftline’s resolution, other files a normal Git merge. The developer’s index and unrelated local edits are kept. A conflict outside the folder is listed in the preflight’s developer_conflicts with no token (Tauri: merge_needs_developer, details { "paths": [...] }) instead of guessing. A force-pushed remote is an ordinary merge, never a rewrite.
Tests incoming_merge_keeps_teammate_code_and_the_developer_index, incoming_merge_leaves_code_conflicts_to_a_developer, bringing_in_the_latest_base_merges_everything_into_the_variation.

Business goal: “Start something new without touching what’s open.”

Question Answer
Covered today? Covered in Rust; not yet exposed to Tauri or TypeScript.
Correct primitive path create_owned_worktree(dir, title, remote, WorktreeStart), owned_worktrees, remove_owned_worktree(name, discard_changes). Open the returned project_path.
Safety behavior Creates a draftline-* worktree on a new namespaced branch, from the last-fetched base (fetch first) or the current version. This checkout is left alone. Removal refuses unsaved work (OwnedWorktreeHasChanges, owned_worktree_has_changes, details { "paths": [...] }) unless discard_changes is set, keeps the branch, and never touches other tools’ worktrees. A failed create leaves nothing behind. Git LFS files arrive as pointer text until someone runs git lfs pull in the new copy. Create, remove, Publish, and Send take Draftline’s operation lock (Send releases it before the pull request); removal also refuses while Draftline runs in that checkout (WorkspaceLocked) or has an unfinished operation there, unless discard_changes.
Tests owned_worktree_starts_from_the_latest_base_and_leaves_this_checkout_alone, removing_an_owned_worktree_keeps_its_branch_and_refuses_unsaved_work, a_failed_owned_worktree_leaves_nothing_behind, removing_an_owned_worktree_waits_for_draftline_running_or_unfinished_there, publishing_and_owned_worktrees_wait_for_another_draftline_operation.

Business goal: “Open a second deck in the same repository.”

Question Answer
Covered today? Covered.
Correct primitive path Draftline manages one folder of the repository: the whole repository or one folder in it. Hosts keep everything they manage inside that folder (CutReady lists its decks in .cutready/projects.json). For an independent copy, use an owned worktree. Before opening, Workspace::probe(path) (Tauri probe_folder, client probeFolder) reports the folder’s state without changing anything: not_a_repository, unmanaged, ready, managed_elsewhere, folder_missing, invalid_marker or newer_format, with the managed folder and labelled suggestions. Pass a suggestion back unchanged to apply_folder_fix (client applyFolderFix), which re-probes, applies it, and opens the result: open, manage_here (start, create the folder, or take over from a missing or invalid one; not offered when .draftline is a folder, which someone has to move), open_managed_folder, manage_renamed_folder (offered first when a commit on the branch renamed the missing folder and at least half its files went to one folder still checked out; the new folder’s own probe puts manage_here first), move_folder_here, restore_folder (from the checked-out commit), switch_to_branch (up to five branches that have the folder) or upgrade_draftline. A fix the probe doesn’t offer fails with FolderFixUnavailable (folder_fix_unavailable). move_folder(to) (Tauri move_folder, client moveFolder) moves the managed folder as one save that also rewrites .draftline: root to a subfolder, subfolder to subfolder (including into its own subfolder), and subfolder to the root only when the repository holds nothing else. Repository files (.draftline, .gitignore, .gitattributes, .gitmodules, .github/) stay at the root and don’t count as something else, unless the folder brings its own copy; they are reserved names for the folder. History follows the folder: each save’s .draftline says where the folder was (and a move’s Draftline-Moved-From: trailer says where it was before, for commits that predate the marker), so versions, history, diffs, previews and restore treat saves from before a move as the folder’s own, and the move is itself a version. Send counts neither those saves nor the move as developer commits. A variation created from a version before a move keeps the folder where it was then; restoring a version onto it fails with FolderIsElsewhere.
Safety behavior The root .draftline file ({ "format": 1, "folder": "cutready" }, "." for the whole repository) names the folder. Opening a folder writes the file if the working tree lacks it, and the next save commits it, so clones and branches agree. A branch without the file falls back to the checkout’s own record in its Git directory (so each linked worktree has its own), and the first folder opened claims a repository with neither. Opening or initializing any other folder, including the repository root or a folder inside the managed one, fails with FolderIsElsewhere (folder_is_elsewhere, details { "folder": "<folder>" }) before anything changes; a refused init removes the folders it created. A path that isn’t a plain folder (a file, or reached through a symlink or junction) is refused with InvalidFolderMarker before anything is written. A handle open on a folder Draftline no longer manages fails every later operation the same way. If the managed folder is missing (renamed, deleted, or absent on the checked-out branch), opening another folder, or the missing folder itself (for example after git mv and a commit), fails with FolderMissing (folder_missing, details { "folder": "<missing folder>" }), so the host can offer the fixes probe lists; opening a path that is gone after .draftline moved on fails with FolderIsElsewhere. init on the missing folder is the probe’s ManageHere (“Create this folder”) and recreates it empty, so hosts should probe first: a renamed folder is offered as manage_renamed_folder ahead of it. Managing a folder never rewrites a .draftline that already says the same thing, so a core.autocrlf checkout keeps it unmodified. A handle on the managed folder gets the same error if the folder disappears while the checked-out commit still tracks it, so a save never records every file as deleted; on a branch that simply lacks the folder, it can still switch back. A .draftline that isn’t valid (unreadable, a folder, or a path outside the repository) fails with InvalidFolderMarker (invalid_folder_marker); deleting it resets it. One written by a newer Draftline fails with NewerFolderMarker (newer_folder_marker), as does the probe’s upgrade_draftline fix, so the host can ask the user to update. The checked-out branch’s .draftline wins: after switching to or pulling a branch that names another folder, handles on the old folder fail with FolderIsElsewhere, so the host reopens the named folder (which can switch back). Restores never change .draftline or the other repository files, preflights never flag it, and Send doesn’t count it as a developer change. A move refuses (FolderMoveRefused, folder_move_refused) a destination with other saved files, a non-empty or reserved one, one reached through a symlink or junction, and unsaved changes (PreflightFailed). Untracked files in a moved subfolder follow it (left_behind lists any that couldn’t); the root’s stay. Other handles on the old folder then fail with FolderIsElsewhere. An interrupted move is recorded; the probe reports ready for either side, and repair or rollback settles on whatever the branch holds: the old folder before the save landed, the new one after. Any other interrupted operation blocks every folder (RecoveryInOtherFolder, recovery_in_other_folder) until the folder that started it finishes it. That folder can always open, even if the checkout’s .draftline now names another folder; then it can only acknowledge (drop) the record, and repair or rollback fail with FolderIsElsewhere so nothing is written to a folder Draftline no longer manages. When the operation itself checked out that marker (a switch, apply or merge interrupted before it finished), the folder the marker names can finish it instead. Merging incoming versions follows the folder too: edits made before a move on either side land in the folder where this side keeps it. If the incoming side moved the folder somewhere else, the merge (and its preflight) fails with IncomingFolderElsewhere (incoming_folder_elsewhere, details { "folder": "<incoming folder>" }); move the folder there, then merge. If the incoming side changed developer files where the folder now is (or anywhere else, when it is now the root), the preflight lists them in developer_conflicts and the merge fails with MergeNeedsDeveloper (merge_needs_developer, details { "paths": [...] }) rather than drop them. Folder fixes recheck their condition and that this folder is still the managed one under the operation lock: restoring re-checks out the folder from HEAD (also after git rm -r), switching is offered only when HEAD does not track the folder, refuses files in the way with PreflightFailed, and with a variation namespace (probe_with_namespace, apply_folder_fix_with_namespace) offers only the base and namespaced branches. A move refuses any change Git would split from its index under the folder (staged or edited files, even ones the content policy skips), and the probe offers a move only when it would succeed; an interrupted move settled on the new side brings untracked leftovers along. A clone opens the folder its HEAD marker names, and an owned worktree opens (and lists) the folder named by its own commit’s marker, so neither strands the host on a pre-move path.
Tests a_checkout_holds_one_workspace, a_refused_init_keeps_folders_that_already_existed, a_root_workspace_refuses_project_folders, each_linked_worktree_holds_its_own_workspace, opening_writes_the_folder_marker_and_the_next_save_commits_it, a_root_folder_marker_names_the_whole_repository_and_survives_restore, a_branch_without_the_marker_keeps_the_same_folder, restoring_a_version_never_brings_back_a_marker_head_lacks, a_committed_marker_corrects_a_stale_local_record, switching_to_a_branch_whose_marker_names_another_folder_follows_it, a_handle_on_a_folder_draftline_no_longer_manages_cant_keep_writing, only_the_project_that_was_interrupted_can_finish_its_recovery, a_switch_interrupted_onto_a_branch_naming_another_folder_can_finish, an_unreadable_record_is_reported_and_deleting_it_resets_it, a_missing_folder_is_reported_until_it_comes_back, opening_a_folder_a_developer_renamed_away_says_it_is_missing, a_folder_a_developer_renamed_is_offered_first_where_it_went, a_deleted_folder_is_not_mistaken_for_a_rename, creating_the_folder_again_leaves_an_identical_marker_untouched, a_branch_without_the_folder_reports_it_missing_but_can_switch_back, a_record_naming_a_folder_outside_the_checkout_is_invalid, an_interrupted_claim_never_leaves_a_partial_one, a_checkout_from_an_older_draftline_is_claimed_on_first_open, concurrent_opens_of_different_folders_let_exactly_one_claim_the_checkout, probing_reports_each_folder_state_with_fixes, an_invalid_marker_is_fixed_by_managing_the_folder_again, managing_a_path_that_is_not_a_plain_folder_changes_nothing, neither_init_nor_a_move_goes_through_a_junction, managing_a_folder_waits_for_an_unfinished_operation, a_fix_the_probe_does_not_offer_is_refused, a_deleted_folder_can_be_restored_from_its_last_save, a_branch_without_the_folder_offers_branches_that_have_it_or_a_new_folder, moving_the_folder_is_one_save_that_rewrites_the_marker, a_folder_can_move_into_its_own_subfolder, the_root_folder_moves_into_a_subfolder_and_back, moves_that_would_mix_with_other_files_are_refused, a_folder_can_be_moved_where_the_host_opened_instead, a_move_interrupted_before_its_save_goes_back_to_the_old_folder, a_move_interrupted_after_its_save_finishes_in_the_new_folder, history_and_restore_follow_the_folder_through_a_move, restoring_onto_a_variation_from_before_a_move_is_refused, a_root_folder_keeps_its_history_after_moving_into_a_subfolder, a_move_before_the_first_save_keeps_older_versions, versions_from_before_the_folder_existed_read_as_empty_after_a_move, a_root_folder_reads_versions_from_before_its_marker_after_moving, a_folder_move_and_older_saves_are_not_developer_commits, incoming_edits_from_before_a_move_land_in_the_moved_folder, an_incoming_move_elsewhere_is_followed_before_merging, a_folder_removed_with_git_rm_can_be_restored, restoring_refuses_once_the_folder_comes_back_in_part, a_namespaced_probe_offers_only_branches_the_host_may_switch_to, switching_to_a_branch_with_the_folder_refuses_untracked_files_in_the_way, a_move_refuses_staged_files_the_folder_skips, a_recovered_move_brings_ignored_leftovers_along, a_clone_opens_the_folder_its_marker_names, owned_worktrees_open_where_their_commit_keeps_the_folder.

Flow 15g: a developer switched the shared checkout

Section titled “Flow 15g: a developer switched the shared checkout”

Business goal: “A developer switched branches; my work must keep going and nothing may be lost.”

Question Answer
Covered today? Covered.
Correct primitive path Nothing special: the business user is a teammate on whatever branch is checked out. Saves, updates and merges work there, and Send moves the work to a namespaced branch (Flow 15c). To keep work apart from the developer’s branch first, call start_variation_here(name) (Tauri start_variation_here, client startVariationHere), or switch_variation back to the base. An owned worktree is the other way out.
Safety behavior Saves land on the checked-out branch, so the developer sees them in their history. On a detached HEAD, saves, save-files, restore-as-new-save, apply_incoming and merges fail with NoCurrentVariation before anything changes; start_variation_here is the way out. start_variation_here only moves HEAD to the new branch: files, the index, and unsaved edits stay as they are, and the developer’s branch is untouched. It refuses names outside the namespace or already taken (VariationAlreadyExists), and refuses if HEAD moved while it ran (LocalStateChanged), removing the branch it made. It creates the branch before moving HEAD, so a crash between them leaves a spare branch, never HEAD on a missing one. On an unborn branch it only moves HEAD. A save pins the branch it started on and rechecks it under its ref locks, so a branch switch mid-save refuses with LocalStateChanged instead of landing on the other branch.
Tests saves_and_updates_work_on_a_developer_branch_like_a_teammates, a_detached_checkout_refuses_saves_until_a_variation_starts_here, a_save_refuses_a_switch_to_another_branch_at_the_same_commit, a_save_refuses_a_switch_to_another_branch_while_it_scans_changes, starting_a_variation_on_an_unborn_developer_branch_keeps_it_unborn, starting_a_variation_refuses_an_existing_name_and_leaves_head_alone, command_contract_saves_on_a_developer_branch_and_starts_a_variation_there, start_variation_here_removes_its_branch_when_head_moves_before_the_switch.

Business goal: “GitHub renamed master to main; my sends must follow, and my saves must not be stranded.”

Question Answer
Covered today? Covered.
Correct primitive path Any fetch (fetch_remote, fetch_all_variations) or Send records the default branch the remote advertises. base_status and Send then target the new name. While a local branch still has the old name, base_status reports it in base_renamed_from (client SyncStatus.base_renamed_from). Saves still land on the old local branch, and Send moves them to a namespaced branch aimed at the new base. A folder that publishes straight to the old name (standalone) sees sync_status go to NoRemoteVersion with base_renamed_from set; rename_variation to the new name, then publish.
Safety behavior The advertised default is cached per remote in the repository’s Git directory (draftline/remotes/, shared by worktrees) and ignored once the remote’s URL changes. Draftline never rewrites refs/remotes/<remote>/HEAD, renames, or deletes the old local branch; removing it is the developer’s call, and the report clears once it is gone. With no earlier record and no refs/remotes/<remote>/HEAD (for example after git init and git push -u), the old name is a local main, master, trunk, or init.defaultBranch branch whose upstream is a same-named branch the remote no longer advertises. Offline, the last recorded default is used. fetch_remote prunes the tracking ref of a current branch the remote no longer has. Publishing the old name fails with BaseRenamed (base_renamed, details {from, to}) rather than bringing it back on the remote.
Tests a_renamed_default_branch_is_followed_and_saves_on_the_old_one_can_move, a_rename_is_noticed_without_origin_head_or_an_earlier_fetch, a_still_advertised_branch_is_not_a_rename, a_renamed_remote_branch_says_so_instead_of_bringing_the_old_name_back.

Business goal: “The developers keep images in Git LFS; save mine there too, and never corrupt theirs.”

Question Answer
Covered today? Yes, where Git and the filter driver are installed; detection and refusal elsewhere.
Correct primitive path filtered_files() lists the folder’s files under a filter driver (FilteredFile { path, driver, driver_configured, driver_runs, content_missing }; driver_runs means Draftline will run the driver here, which runs_filter(driver) also answers). filtered_files() lists tracked files only; filtered_files_among(paths) probes paths that need not exist or be tracked, so a host can check before it writes a screenshot. Like every Draftline path, they are repository-relative (cutready/shot.png), and results use /; a folder-relative path is outside the folder and fails with path_outside_content_policy. inspect warns with LfsContentMissing when the folder’s files hold LFS pointer text because the clone never downloaded their content. Client: filteredFiles, Tauri: filtered_files. preflight_send and preflight_publish list outgoing_files (OutgoingFile { path, commit, size, kind, uploaded }, kind is large or lfs_pointer; uploaded means the push uploads that pointer’s content first, so only an lfs_pointer with uploaded: false would reach the remote without it) from commits the remote doesn’t advertise, so the host can block a push it shouldn’t make. outgoing_files_truncated means the scan stopped early; treat it as unknown. Pass SendPreflight.token as SendRequest.expected so Send refuses (local_state_changed) if the remote, base, branch, target branch, or commit differs from what the host approved. The token describes state, not an attempt: it stays valid while that state is unchanged, so a Send that changed nothing can be repeated with it, while a failed Send that already moved to its new branch needs a fresh preflight. expected is optional so older hosts keep working; hosts should always pass it.
Safety behavior libgit2 cannot run filter drivers, so Draftline runs them through the git executable, as Git would, for every file whose driver the repository configures (for example after git lfs install). A save stores the clean filter’s output (an LFS pointer, with the content in .git/lfs/objects); restores, switches, discards, shelves, and incoming changes write the smudged content; and publish and Send run git lfs push for the branch before pushing it. A failed filter or upload stops the operation with GitFilterFailed (git_filter_failed, a short message; details { action, output } carry Git’s output) and pushes nothing. A pointer whose content isn’t in the local LFS cache fails the upload too. git lfs push authenticates with Git’s credential helpers, not the host’s remote callbacks. RemoteOptions::with_lfs_timeout bounds the upload: past it, git and the processes it started are stopped, the branch isn’t pushed (LFS objects uploaded before then stay on the LFS server), and the operation fails with RemoteOperationTimedOut (remote_operation_timed_out, operation upload_lfs_content). Workspace::set_filter_timeout bounds each filter run on save (failing with git_filter_failed) and each smudge (which, like a failed download, leaves pointers that inspect reports), and the index refresh that follows them (which runs on a private copy of the index, so a stopped refresh never holds or removes Git’s index lock). Both default to no timeout. Workspace::smudge_pointer_files retries the download: it writes the real content of every filtered file in the folder still holding the exact pointer HEAD stores, so a host can offer “retry download” once the driver or server recovers. Draftline reads the driver’s configuration through libgit2, which may not see a configuration file only the git executable reads (such as a bundled Git’s system gitconfig); configure the driver globally or in the repository. Checkouts smudge their files in one git checkout-index, so a long-running filter such as git-lfs filter-process serves them all. A smudge never replaces a file someone changed after the checkout wrote it. Repairing or rolling back an interrupted operation also smudges any filtered file still holding the exact pointer HEAD stores, which a process killed between the checkout and the smudge leaves behind (a pointer cleans to the same blob, so it would never show as changed). A file whose content can’t be downloaded is left as its pointer, as GIT_LFS_SKIP_SMUDGE would, and inspect reports it. Where the driver isn’t configured or git isn’t installed (mobile), saves, restores, discards, and checkouts touching a filtered path refuse before writing. A checkout hazard names whose file it is: GitFilter for the project’s, GitFilterOutsideProject for a developer’s file outside the folder, which someone with Git and the driver must update. Pointer files are never read as content by the report; the host should not show them as images. The outgoing report never blocks; an lfs_pointer there is uploaded first when the driver runs, and otherwise reaches the remote without its content.
Tests a_clone_without_lfs_content_is_reported_not_read_as_content, smudged_lfs_files_are_not_reported_missing, a_developer_lfs_change_outside_the_folder_is_named_as_theirs, command_contract_lists_and_probes_filtered_files, send_and_publish_preflights_report_large_files_and_lfs_pointers_going_out, a_save_stores_new_lfs_files_as_pointers_and_keeps_the_real_file, restore_switch_and_discard_write_real_lfs_content, shelving_keeps_lfs_content, publishing_uploads_lfs_content_before_the_branch, a_pointer_whose_content_is_missing_is_never_pushed, a_failed_upload_refuses_the_push (tests/git_lfs.rs; CI sets DRAFTLINE_REQUIRE_GIT_LFS so they cannot skip).
Gap Diffs compare LFS files as pointers. Owned worktrees (Flow 14) still check out pointers.