diff --git a/README.md b/README.md index a6bb774..62cd4b2 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,19 @@ the resolved app appearance automatically. merge, and a fully keyboard-operable interactive rebase (reorder, reword, edit/pause-to-amend, squash, fixup, drop, and merge preservation) with pause/conflict Continue / Abort. +- **Patch interchange** — preview affected paths and validate patch imports + into the working tree, index, or both; import mailboxes with original authors + and Continue / Skip / Abort recovery; verify bundle refs and prerequisites, + import into a new branch, and export full or incremental bundles. +- **Git-flow workflows** — opt-in Git-flow AVH detection and repository + configuration, reviewed feature/release/hotfix start and finish, streamed + output, and merge recovery that retains already completed stages. +- **Advanced Git refs** — inspect and edit Git notes and replacement refs; + retarget or re-annotate existing unsigned tags with old/new targets and + optional remote publication checks. External edits require a fresh review. +- **Guided bisect** — start from known good/bad revisions, test and mark + good/bad/skip, inspect remaining candidates and the culprit, resume external + sessions, and reset to the original checkout while protecting test edits. - **Commit graph** — SVG lanes with branch/tag chips, revealable inline stash nodes with non-mutating diff inspection, a resizable commit detail panel with lazy GPG/SSH/X.509 verification, diff --git a/ROADMAP.md b/ROADMAP.md index c8f5fe1..76b0fb7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2154,7 +2154,8 @@ and Store certification remain external gates. never touches credentials). Designed 2026-06-12: `docs/remote-ssh.md` + task breakdown in TASKS.md. Pre-1.0 guardrails (opaque repo paths, everything through the `commands.rs` seam) are active now. -- Git-flow (start/finish feature/release/hotfix; shells out to `git-flow`) +- ☑ Git-flow (F19: opt-in Git-flow AVH detection/configuration, reviewed + feature/release/hotfix start/finish and recovery from partial completion) - ☑ Git LFS — filter-correct staging/checkout/discard/hard reset and explicit setup, patterns, object/transfer status and server locks (`LfsDialog`, `Repo::lfs_action`); real Git fixtures and native dialog verification pass. @@ -2164,9 +2165,14 @@ and Store certification remain external gates. GitHub Releases channel) - Opt-in product telemetry only if a concrete post-1.0 decision, disclosure, retention policy, and backend justify adding it -- Guided Git bisect +- ☑ Guided Git bisect (F10: manual good/bad/skip, progress/culprit, + external-session resume, clean-tree reset and reviewed original target) +- ☑ Advanced refs (F18: Git notes, replace refs, explicit tag retarget/re-annotation + with stale-write guards and remote publication checks; native desktop verified) - Sparse checkout (cone mode first) -- Patch import/mailbox and Git bundle workflows +- ☑ Patch import/mailbox and Git bundle workflows (F07: `InterchangeDialog`, + validation, author-preserving mailbox recovery and new-branch bundle import; + native desktop flows verified) - ☑ Expanded submodule lifecycle — guarded add/remove/deinit/sync/URL, paged nested inspection and cancellable updates (`SubmoduleDialog`); real Git preservation fixtures and native lifecycle/keyboard checks pass. @@ -2814,6 +2820,36 @@ implementation rows while the July audit is labeled historical. This is a planning update, not a claim that these features shipped; existing local Git, GitHub/Azure review, Workbench and performance work retain their own status. +**Advanced refs and Git-flow shipped (2026-09-06):** Added lazy Git notes and +replacement-ref management, explicit unsigned tag retarget/re-annotation with +current/new target review and remote publication checks, and opt-in Git-flow +AVH configuration plus feature/release/hotfix start/finish. Stale external refs, +checkout/config changes and unresolved conflicts block reviewed writes. +Git-flow streams bounded output and preserves completed merges/tags when a +later merge is aborted. Native WebView2 exercised notes, replacements, tag +edits, all three workflow types, external-session recovery and keyboard focus. +The core suite passed (174 normal tests plus three explicit AVH integrations), +as did the frontend typecheck and five focused frontend tests. + +--- + +**Patch interchange shipped (2026-09-06):** Added affected-path previews, +explicit index/worktree targets, validated import, original-author mailboxes +with Continue/Skip/Abort, and bundle verification/import/export with +prerequisite/ref summaries. Native fixtures cover stale previews, all patch +targets, mailbox authors/recovery and missing bundle prerequisites. Rust +checks, five fixtures and three frontend IPC tests pass. Native WebView2 +verified palette/focus, worktree/index targets, mailbox authors and conflict +continuation, and bundle verification/import/incremental export. + +**Guided bisect shipped (2026-09-06):** The Repository menu, palette and +operation banner open a manual bisect dialog backed by worktree-local Git +state. It shows remaining candidates, final/ambiguous outcomes, custom +external terms and no-checkout sessions. Stale ratings and dirty checkout/ +reset transitions are refused. Three native fixtures, two frontend tests, +Rust checks/typecheck and native WebView2 rating/resume/reset/keyboard flows +pass. Automated test-command execution remains a separate later slice. + **Personal user actions shipped (2026-09-06, F15):** Settings → Integrations now edits explicit executable/argv definitions for repositories, qualified refs, and working-tree files. Context menus and Quick Launch capture the target and diff --git a/TASKS.md b/TASKS.md index a72457a..3cee753 100644 --- a/TASKS.md +++ b/TASKS.md @@ -115,9 +115,12 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git- ignored and nested files, unrecorded commits and `.gitmodules`/index preservation fixtures pass. Native lifecycle, keyboard/palette, module opening, destructive guards and cancellation checks pass. -- ☐ **F07 / P2 — Patch/mailbox/bundle import and interchange.** Build on exact +- ☑ **F07 / P2 — Patch/mailbox/bundle import and interchange.** Build on exact patch export and hunk apply with preview/validation, explicit targets, mailbox continue/skip/abort and bundle prerequisites/ref summaries. + Implemented (`interchange.rs`, `InterchangeDialog`, Repository menu/palette); + five native fixtures and three IPC tests pass. Native WebView2 verified patch + targets, authored mailbox/conflict continuation and bundle import/export. - ☑ **F08 / P2 — Sparse checkout.** Cone-directory inspect/change/disable and compatibility fixtures for excluded paths, dirty trees and sparse indexes (`Repo::set_sparse_checkout`, `SparseCheckoutDialog`, `sparse_checkout.rs` fixtures). @@ -127,9 +130,11 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git- `repo_expand_history`, `CloneScopeDialog`; `docs/sparse-clone-verification.md`). Follow-up: verify real LFS clone checkout on Git 2.45.1 / LFS 3.5.1; see `docs/git-assets-validation-2026-09-06.md` for the hook rejection. -- ☐ **F10 / P2 — Guided bisect.** Good/bad/skip, operation progress, external +- ☑ **F10 / P2 — Guided bisect.** Good/bad/skip, operation progress, external session resume and safe reset to the original checkout; defer test-command - execution until the manual workflow is complete. + execution (`bisect.rs`, `BisectDialog`, Repository menu/palette and banner; + three native fixtures, two frontend tests and native WebView2 keyboard, + external-resume, dirty-reset and original-target checks). - ☐ **F14 / P2 — Publish a new hosted repository.** Provider/account/visibility selection, concrete destination review, remote configuration and explicit initial push, with recovery from partial failure. @@ -137,11 +142,14 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git- argv templates, exact context, palette/menu discovery, preview, bounded output and cancellation. (`UserActionsEditor`, `UserActionDialog`, `repo_user_action_preview` / `repo_user_action_run`; personally persisted settings.) -- ☐ **F18 / P3 — Advanced refs.** Git notes/replace-ref management and explicit +- ☑ **F18 / P3 — Advanced refs.** Git notes/replace-ref management and explicit tag retarget/re-annotation with current/new target review. Signed tags are F03; - existing local Review notes are separate from Git notes. -- ☐ **F19 / P3 — Git-flow orchestration.** Opt-in tool/config detection and + existing local Review notes are separate from Git notes. (`AdvancedRefsDialog`, + locked notes updates, replace refs and compare-and-swap tag edits; native verified.) +- ☑ **F19 / P3 — Git-flow orchestration.** Opt-in tool/config detection and inspectable start/finish feature/release/hotfix flows with conflict recovery. + (`GitflowDialog`, AVH command review/streaming, locked opt-in configuration + and merge-only recovery; all three flows and stale state verified natively.) Hosted-review F06/F11–F13 stay in the Pull requests backlog below. CLI/deep-link F16 and remote-SSH F17 keep their existing Platform / CLI companion / Remote diff --git a/crates/strand-core/src/advanced_refs.rs b/crates/strand-core/src/advanced_refs.rs new file mode 100644 index 0000000..698884e --- /dev/null +++ b/crates/strand-core/src/advanced_refs.rs @@ -0,0 +1,667 @@ +//! Lazy Git notes/replacements and explicit, compare-and-swap tag editing. +use crate::{Error, Repo, Result}; +use serde::{Deserialize, Serialize}; + +const LIMIT: usize = 2000; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ObjectSummary { + pub oid: String, + pub kind: String, + pub subject: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::interchange::InterchangeScratch; + fn git(path: &std::path::Path, args: &[&str]) -> String { + let out = crate::git_command() + .current_dir(path) + .args(crate::GIT_SAFE_CONFIG) + .args(args) + .output() + .unwrap(); + assert!( + out.status.success(), + "{:?}: {}", + args, + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().into() + } + fn fixture() -> (InterchangeScratch, Repo, Vec) { + let scratch = InterchangeScratch::new().unwrap(); + let raw = git2::Repository::init(&scratch.0).unwrap(); + let mut config = raw.config().unwrap(); + config.set_str("user.name", "Refs Tester").unwrap(); + config.set_str("user.email", "refs@example.test").unwrap(); + config.set_bool("commit.gpgsign", false).unwrap(); + config.set_bool("tag.gpgsign", false).unwrap(); + config.set_str("core.hooksPath", "/dev/null").unwrap(); + let mut ids = vec![]; + for i in 0..3 { + std::fs::write(scratch.0.join("file"), format!("{i}\n")).unwrap(); + git(&scratch.0, &["add", "."]); + git(&scratch.0, &["commit", "-m", &format!("step {i}")]); + ids.push(git(&scratch.0, &["rev-parse", "HEAD"])); + } + let repo = Repo::discover(&scratch.0).unwrap(); + (scratch, repo, ids) + } + #[test] + fn notes_preserve_namespaces_external_edits_and_worktree_sharing() { + let (scratch, repo, ids) = fixture(); + let ns = "refs/notes/commits"; + repo.write_git_note(ns, &ids[0], None, Some("first note")) + .unwrap(); + let note = repo.git_note(ns, &ids[0]).unwrap(); + assert_eq!(note.message.as_deref(), Some("first note")); + git( + &repo.path, + &["notes", "add", "-m", "external note", &ids[1]], + ); + assert!(repo + .write_git_note(ns, &ids[0], note.ref_tip.as_deref(), Some("stale")) + .is_err()); + assert_eq!( + repo.git_note(ns, &ids[1]).unwrap().message.as_deref(), + Some("external note\n") + ); + let fresh = repo.git_note(ns, &ids[0]).unwrap(); + repo.write_git_note(ns, &ids[0], fresh.ref_tip.as_deref(), Some("edited")) + .unwrap(); + repo.write_git_note("refs/notes/other", &ids[0], None, Some("separate")) + .unwrap(); + let link = scratch.0.join("linked"); + git( + &repo.path, + &["worktree", "add", "-b", "linked", link.to_str().unwrap()], + ); + let linked = Repo::discover(&link).unwrap(); + let fresh = linked.git_note(ns, &ids[0]).unwrap(); + assert_eq!(fresh.message.as_deref(), Some("edited")); + linked + .write_git_note(ns, &ids[0], fresh.ref_tip.as_deref(), None) + .unwrap(); + assert!(repo.git_note(ns, &ids[0]).unwrap().message.is_none()); + assert_eq!(repo.advanced_refs(ns).unwrap().notes.len(), 1); + assert_eq!( + repo.git_note("refs/notes/other", &ids[0]) + .unwrap() + .message + .as_deref(), + Some("separate") + ); + assert!(git(&repo.path, &["for-each-ref", "refs/strand/notes-"]).is_empty()); + } + #[test] + fn replacements_reject_cycles_type_mismatch_and_stale_writes() { + let (_s, repo, ids) = fixture(); + repo.write_replacement(&ids[0], Some(&ids[1]), None) + .unwrap(); + assert_eq!( + repo.review_replacement(&ids[0], &ids[2]) + .unwrap() + .original + .oid, + ids[0] + ); + assert!(repo.review_replacement(&ids[1], &ids[0]).is_err()); + assert!(repo.review_replacement(&ids[0], "HEAD:file").is_err()); + assert!(repo + .write_replacement(&ids[0], Some(&ids[2]), None) + .is_err()); + repo.write_replacement(&ids[0], Some(&ids[2]), Some(&ids[1])) + .unwrap(); + assert_eq!( + git( + &repo.path, + &["--no-replace-objects", "show", "-s", "--format=%s", &ids[0]] + ), + "step 0" + ); + assert!(repo + .write_replacement(&ids[0], None, Some(&ids[1])) + .is_err()); + repo.write_replacement(&ids[0], None, Some(&ids[2])) + .unwrap(); + assert!(repo + .advanced_refs("refs/notes/commits") + .unwrap() + .replacements + .is_empty()); + } + #[test] + fn tag_edit_preserves_kind_annotation_and_detects_publication_and_staleness() { + let (scratch, repo, ids) = fixture(); + git(&repo.path, &["tag", "light", &ids[0]]); + repo.edit_tag("light", &ids[1], &ids[0], TagEditKind::Retarget, None) + .unwrap(); + assert_eq!( + git(&repo.path, &["cat-file", "-t", "refs/tags/light"]), + "commit" + ); + assert!(repo + .edit_tag("light", &ids[2], &ids[0], TagEditKind::Retarget, None) + .is_err()); + git( + &repo.path, + &["tag", "-a", "annotated", "-m", "preserve me", &ids[0]], + ); + let review = repo.review_tag_edit("annotated", &ids[1]).unwrap(); + assert_eq!(review.changed_files, 1); + repo.edit_tag( + "annotated", + &ids[1], + &review.ref_oid, + TagEditKind::Retarget, + None, + ) + .unwrap(); + let review = repo.review_tag_edit("annotated", &ids[1]).unwrap(); + assert_eq!(review.annotation.as_deref(), Some("preserve me\n")); + assert!(repo + .edit_tag( + "annotated", + &ids[2], + &review.ref_oid, + TagEditKind::Reannotate, + Some("new") + ) + .is_err()); + repo.edit_tag( + "annotated", + &ids[1], + &review.ref_oid, + TagEditKind::Reannotate, + Some("new annotation"), + ) + .unwrap(); + let bare = scratch.0.join("remote.git"); + git2::Repository::init_bare(&bare).unwrap(); + git( + &repo.path, + &["remote", "add", "origin", bare.to_str().unwrap()], + ); + assert!(repo + .published_tag("origin", "annotated") + .unwrap() + .oid + .is_none()); + git(&repo.path, &["push", "origin", "refs/tags/annotated"]); + let review = repo.review_tag_edit("annotated", &ids[2]).unwrap(); + assert_eq!( + repo.published_tag("origin", "annotated") + .unwrap() + .oid + .as_deref(), + Some(review.ref_oid.as_str()) + ); + repo.edit_tag( + "annotated", + &ids[2], + &review.ref_oid, + TagEditKind::Retarget, + None, + ) + .unwrap(); + assert_ne!( + repo.published_tag("origin", "annotated").unwrap().oid, + Some(repo.review_tag_edit("annotated", &ids[2]).unwrap().ref_oid) + ); + let raw = repo.git2_owned().unwrap(); + raw.tag( + "signed", + &raw.find_object(git2::Oid::from_str(&ids[0]).unwrap(), None) + .unwrap(), + &raw.signature().unwrap(), + "signed\n-----BEGIN SSH SIGNATURE-----\nfixture", + false, + ) + .unwrap(); + let signed = repo.review_tag_edit("signed", &ids[1]).unwrap(); + assert!(signed.signed); + assert!(repo + .edit_tag( + "signed", + &ids[1], + &signed.ref_oid, + TagEditKind::Retarget, + None + ) + .is_err()); + } +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NoteEntry { + pub object: String, + pub note: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReplaceEntry { + pub original: String, + pub replacement: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdvancedRefs { + pub notes_refs: Vec, + pub notes_tip: Option, + pub notes: Vec, + pub notes_truncated: bool, + pub replacements: Vec, + pub replacements_truncated: bool, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitNote { + pub target: ObjectSummary, + pub ref_tip: Option, + pub message: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReplaceReview { + pub original: ObjectSummary, + pub replacement: ObjectSummary, + pub previous: Option, +} +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TagEditKind { + Retarget, + Reannotate, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TagEditReview { + pub name: String, + pub ref_oid: String, + pub current: ObjectSummary, + pub proposed: ObjectSummary, + pub annotation: Option, + pub signed: bool, + pub changed_files: usize, + pub remotes: Vec, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PublishedTag { + pub remote: String, + pub oid: Option, +} + +fn notes_name(name: &str) -> Result<()> { + if !name.starts_with("refs/notes/") || !git2::Reference::is_valid_name(name) { + return Err(Error::Other( + "use a full notes ref such as refs/notes/commits".into(), + )); + } + Ok(()) +} +fn ref_tip(repo: &git2::Repository, name: &str) -> Result> { + match repo.find_reference(name) { + Ok(r) => r + .target() + .map(|o| Some(o.to_string())) + .ok_or_else(|| Error::Other("symbolic refs cannot be edited here".into())), + Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} +fn summary(object: &git2::Object<'_>) -> ObjectSummary { + ObjectSummary { + oid: object.id().to_string(), + kind: object.kind().map(|k| k.str()).unwrap_or("unknown").into(), + subject: object + .as_commit() + .and_then(|c| c.summary()) + .or_else(|| object.as_tag().and_then(|t| t.name())) + .unwrap_or("") + .into(), + } +} +fn expect_tip(repo: &git2::Repository, name: &str, expected: Option<&str>) -> Result<()> { + if ref_tip(repo, name)?.as_deref() != expected { + return Err(Error::Other( + "reference changed externally; inspect it again before editing".into(), + )); + } + Ok(()) +} + +impl Repo { + pub fn advanced_refs(&self, notes_ref: &str) -> Result { + notes_name(notes_ref)?; + let repo = self.git2_owned()?; + let mut notes_refs = vec![]; + for reference in repo.references_glob("refs/notes/*")? { + if let Some(name) = reference?.name() { + notes_refs.push(name.to_owned()); + } + if notes_refs.len() >= LIMIT { + break; + } + } + let notes_tip = ref_tip(&repo, notes_ref)?; + let mut notes = vec![]; + if notes_tip.is_some() { + for entry in repo.notes(Some(notes_ref))?.take(LIMIT + 1) { + let (note, object) = entry?; + notes.push(NoteEntry { + object: object.to_string(), + note: note.to_string(), + }); + } + } + let notes_truncated = notes.len() > LIMIT; + notes.truncate(LIMIT); + let mut replacements = vec![]; + for reference in repo.references_glob("refs/replace/*")?.take(LIMIT + 1) { + let reference = reference?; + if let (Some(original), Some(target)) = ( + reference + .name() + .and_then(|n| n.strip_prefix("refs/replace/")), + reference.target(), + ) { + replacements.push(ReplaceEntry { + original: original.to_owned(), + replacement: target.to_string(), + }); + } + } + let replacements_truncated = replacements.len() > LIMIT; + replacements.truncate(LIMIT); + Ok(AdvancedRefs { + notes_refs, + notes_tip, + notes, + notes_truncated, + replacements, + replacements_truncated, + }) + } + + pub fn git_note(&self, notes_ref: &str, revision: &str) -> Result { + notes_name(notes_ref)?; + let repo = self.git2_owned()?; + let target = repo.revparse_single(revision)?; + let tip = ref_tip(&repo, notes_ref)?; + let message = match repo.find_note(Some(notes_ref), target.id()) { + Ok(note) => { + if note.message_bytes().len() > 1024 * 1024 { + return Err(Error::Other("note exceeds the 1 MiB editing limit".into())); + } + Some( + note.message() + .ok_or_else(|| { + Error::Other("this note is not UTF-8 and cannot be edited here".into()) + })? + .to_owned(), + ) + } + Err(e) if e.code() == git2::ErrorCode::NotFound => None, + Err(e) => return Err(e.into()), + }; + Ok(GitNote { + target: summary(&target), + ref_tip: tip, + message, + }) + } + + pub fn write_git_note( + &self, + notes_ref: &str, + object: &str, + expected: Option<&str>, + message: Option<&str>, + ) -> Result<()> { + notes_name(notes_ref)?; + if message.is_some_and(|m| m.trim().is_empty() || m.len() > 1024 * 1024) { + return Err(Error::Other( + "notes must contain text and be at most 1 MiB; use Remove to delete a note".into(), + )); + } + let repo = self.git2_owned()?; + let object = git2::Oid::from_str(object)?; + repo.find_object(object, None)?; + let mut transaction = repo.transaction()?; + transaction.lock_ref(notes_ref)?; + expect_tip(&repo, notes_ref, expected)?; + // libgit2's notes writer publishes a ref. Write through a private temporary + // ref, then publish its commit to the locked real namespace. External notes + // updates cannot be lost between reading the tree and replacing its tip. + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + let temporary = format!( + "refs/strand/notes-{}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| Error::Other(e.to_string()))? + .as_nanos(), + NEXT.fetch_add(1, Ordering::Relaxed) + ); + if ref_tip(&repo, &temporary)?.is_some() { + return Err(Error::Other("temporary notes ref already exists".into())); + } + let signature = repo.signature()?; + if let Some(tip) = expected { + repo.reference( + &temporary, + git2::Oid::from_str(tip)?, + false, + "Strand notes preparation", + )?; + } + let result = (|| -> Result<()> { + match message { + Some(text) => { + repo.note(&signature, &signature, Some(&temporary), object, text, true)?; + } + None => repo.note_delete(object, Some(&temporary), &signature, &signature)?, + } + let tip = repo + .find_reference(&temporary)? + .target() + .ok_or_else(|| Error::Other("notes writer returned a symbolic ref".into()))?; + transaction.set_target(notes_ref, tip, Some(&signature), "Strand edit Git note")?; + transaction.commit()?; + Ok(()) + })(); + if let Ok(mut reference) = repo.find_reference(&temporary) { + let _ = reference.delete(); + } + result + } + + pub fn review_replacement(&self, original: &str, replacement: &str) -> Result { + let repo = self.git2_owned()?; + let old = repo.revparse_single(original)?; + let new = repo.revparse_single(replacement)?; + if old.kind() != new.kind() { + return Err(Error::Other( + "replacement objects must have the same Git object type".into(), + )); + } + let mut next = new.id(); + let mut visited = std::collections::HashSet::new(); + loop { + if next == old.id() || !visited.insert(next) { + return Err(Error::Other("replacement would create a cycle".into())); + } + let Some(target) = ref_tip(&repo, &format!("refs/replace/{next}"))? else { + break; + }; + next = git2::Oid::from_str(&target)?; + if visited.len() >= 5 { + return Err(Error::Other( + "replacement chain exceeds Git's supported depth".into(), + )); + } + } + Ok(ReplaceReview { + original: summary(&old), + replacement: summary(&new), + previous: ref_tip(&repo, &format!("refs/replace/{}", old.id()))?, + }) + } + + pub fn write_replacement( + &self, + original: &str, + replacement: Option<&str>, + expected: Option<&str>, + ) -> Result<()> { + let original = git2::Oid::from_str(original)?.to_string(); + let repo = self.git2_owned()?; + let refname = format!("refs/replace/{original}"); + let mut transaction = repo.transaction()?; + transaction.lock_ref(&refname)?; + expect_tip(&repo, &refname, expected)?; + if let Some(replacement) = replacement { + let review = self.review_replacement(&original, replacement)?; + let target = git2::Oid::from_str(&review.replacement.oid)?; + transaction.set_target(&refname, target, None, "Strand edit replacement")?; + } else { + if expected.is_none() { + return Err(Error::Other("no replacement exists for this object".into())); + } + transaction.remove(&refname)?; + } + transaction.commit()?; + Ok(()) + } + + pub fn review_tag_edit(&self, name: &str, target: &str) -> Result { + let repo = self.git2_owned()?; + let refname = format!("refs/tags/{name}"); + if !git2::Reference::is_valid_name(&refname) { + return Err(Error::Other("invalid tag name".into())); + } + let ref_oid = + ref_tip(&repo, &refname)?.ok_or_else(|| Error::Other("tag no longer exists".into()))?; + let raw = repo.find_object(git2::Oid::from_str(&ref_oid)?, None)?; + let current = raw.peel_to_commit()?; + let proposed = repo.revparse_single(target)?.peel_to_commit()?; + let annotation = if let Some(tag) = raw.as_tag() { + let message = tag + .message() + .ok_or_else(|| Error::Other("tag annotation is not UTF-8".into()))?; + if message.len() > 1024 * 1024 { + return Err(Error::Other("annotation exceeds 1 MiB".into())); + } + Some(message.to_owned()) + } else { + None + }; + let signed = annotation.as_deref().is_some_and(|m| { + [ + "-----BEGIN PGP SIGNATURE-----", + "-----BEGIN SSH SIGNATURE-----", + "-----BEGIN SIGNED MESSAGE-----", + ] + .iter() + .any(|marker| m.contains(marker)) + }); + let changed_files = repo + .diff_tree_to_tree(Some(¤t.tree()?), Some(&proposed.tree()?), None)? + .deltas() + .len(); + let remotes = repo + .remotes()? + .iter() + .flatten() + .map(str::to_owned) + .collect(); + Ok(TagEditReview { + name: name.into(), + ref_oid, + current: summary(current.as_object()), + proposed: summary(proposed.as_object()), + annotation, + signed, + changed_files, + remotes, + }) + } + + pub fn edit_tag( + &self, + name: &str, + target: &str, + expected: &str, + kind: TagEditKind, + message: Option<&str>, + ) -> Result<()> { + let review = self.review_tag_edit(name, target)?; + if review.ref_oid != expected { + return Err(Error::Other( + "tag changed externally; review the targets again".into(), + )); + } + if review.signed { + return Err(Error::Other( + "editing this signed tag requires a new signature; use the signed-tag workflow" + .into(), + )); + } + if kind == TagEditKind::Reannotate && review.current.oid != review.proposed.oid { + return Err(Error::Other( + "re-annotation must keep the current target; use Retarget for a different commit" + .into(), + )); + } + let repo = self.git2_owned()?; + let object = repo.find_object(git2::Oid::from_str(&review.proposed.oid)?, None)?; + let annotation = if kind == TagEditKind::Reannotate { + let text = message + .filter(|m| !m.trim().is_empty()) + .ok_or_else(|| Error::Other("an annotation is required".into()))?; + if text.len() > 1024 * 1024 { + return Err(Error::Other("annotation exceeds 1 MiB".into())); + } + Some(text) + } else { + review.annotation.as_deref() + }; + let oid = if let Some(annotation) = annotation { + repo.tag_annotation_create(name, &object, &repo.signature()?, annotation)? + } else { + object.id() + }; + repo.reference_matching( + &format!("refs/tags/{name}"), + oid, + true, + git2::Oid::from_str(expected)?, + "Strand edit tag", + )?; + Ok(()) + } + + pub fn published_tag(&self, remote: &str, name: &str) -> Result { + let repo = self.git2_owned()?; + repo.find_remote(remote)?; + let refname = format!("refs/tags/{name}"); + if remote.starts_with('-') || !git2::Reference::is_valid_name(&refname) { + return Err(Error::Other("invalid remote or tag name".into())); + } + let result = crate::network::run_git_streaming_transcript( + &self.path, + &["ls-remote", "--refs", "--tags", "--", remote, &refname], + |_| {}, + None, + )?; + if !result.success { + return Err(Error::Other(result.output)); + } + let oid = result.output.lines().find_map(|line| { + let mut fields = line.split_whitespace(); + let oid = fields.next()?; + (fields.next()? == refname).then(|| oid.to_owned()) + }); + Ok(PublishedTag { + remote: remote.into(), + oid, + }) + } +} diff --git a/crates/strand-core/src/bisect.rs b/crates/strand-core/src/bisect.rs new file mode 100644 index 0000000..808cb3f --- /dev/null +++ b/crates/strand-core/src/bisect.rs @@ -0,0 +1,423 @@ +//! Manual bisect, driven by Git's worktree-local state (including external runs). +use crate::{Error, Repo, Result}; +use serde::{Deserialize, Serialize}; +use std::{fs, io::Read, path::Path, process::Stdio}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BisectState { + pub active: bool, + pub token: String, + pub original: String, + pub original_tip: String, + pub current: String, + pub subject: String, + pub expected: String, + pub good_term: String, + pub bad_term: String, + pub remaining: usize, + pub remaining_truncated: bool, + pub range_error: String, + pub culprit: Option, + pub ambiguous: bool, + pub no_checkout: bool, + pub clean: bool, + pub log: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BisectAction { + Good, + Bad, + Skip, + Reset, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BisectOutcome { + pub success: bool, + pub output: String, + pub state: BisectState, +} + +fn git(cwd: &Path, args: &[&str]) -> Result { + Ok(crate::git_command() + .current_dir(cwd) + .args(crate::GIT_SAFE_CONFIG) + .args(args) + .env("LC_ALL", "C") + .env("GIT_EDITOR", "true") + .env("GIT_TERMINAL_PROMPT", "0") + .stdin(Stdio::null()) + .output()?) +} +fn text(out: &std::process::Output) -> String { + String::from_utf8_lossy( + &out.stdout + .iter() + .chain(&out.stderr) + .take(65536) + .copied() + .collect::>(), + ) + .trim() + .to_owned() +} +fn checked(cwd: &Path, args: &[&str]) -> Result { + let out = git(cwd, args)?; + if !out.status.success() { + return Err(Error::Other(text(&out))); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned()) +} +fn state_file(dir: &Path, name: &str) -> Result { + let file = match fs::File::open(dir.join(name)) { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(String::new()), + Err(e) => return Err(e.into()), + }; + let mut text = String::new(); + file.take(2 * 1024 * 1024 + 1).read_to_string(&mut text)?; + if text.len() > 2 * 1024 * 1024 { + return Err(Error::Other("bisect state is too large to inspect".into())); + } + Ok(text.trim().into()) +} + +impl Repo { + pub fn bisect_state(&self) -> Result { + let dir = self.git_dir(); + let active = dir.join("BISECT_START").exists(); + let original = state_file(dir, "BISECT_START")?; + let log = state_file(dir, "BISECT_LOG")?; + let expected = state_file(dir, "BISECT_EXPECTED_REV")?; + let terms = state_file(dir, "BISECT_TERMS")?; + let mut terms = terms.lines(); + let bad_term = terms.next().unwrap_or("bad").to_string(); + let good_term = terms.next().unwrap_or("good").to_string(); + let no_checkout = dir.join("BISECT_HEAD").exists(); + let fresh = self.git2_owned()?; + let original_tip = fresh + .find_reference(&format!("refs/heads/{original}")) + .and_then(|r| r.peel_to_commit()) + .or_else(|_| fresh.revparse_single(&original)?.peel_to_commit()) + .map(|c| c.id().to_string()) + .unwrap_or_default(); + let revision = if no_checkout { "BISECT_HEAD" } else { "HEAD" }; + let commit = fresh.revparse_single(revision)?.peel_to_commit()?; + let current = commit.id().to_string(); + let subject = commit.summary().unwrap_or("").to_owned(); + let status = checked( + &self.path, + &["status", "--porcelain", "--untracked-files=normal"], + )?; + let clean = status.is_empty(); + let candidates = if active { + checked( + &self.path, + &[ + "bisect", + "visualize", + "--format=%H", + "--no-patch", + "--max-count=10001", + ], + ) + } else { + Ok(String::new()) + }; + let (candidates, range_error) = match candidates { + Ok(value) => (value, String::new()), + Err(e) => (String::new(), e.to_string()), + }; + let remaining = candidates + .lines() + .filter(|l| l.len() == 40 || l.len() == 64) + .count(); + let culprit = log.lines().rev().find_map(|line| { + let prefix = format!("# first {bad_term} commit: ["); + let quoted_prefix = format!("# first '{bad_term}' commit: ["); + line.strip_prefix(&prefix) + .or_else(|| line.strip_prefix("ed_prefix)) + .and_then(|s| s.split_once(']')) + .map(|(oid, _)| oid.to_owned()) + }); + let ambiguous = + active && culprit.is_none() && log.contains("# only skipped commits left to test"); + let refs = checked( + &self.path, + &[ + "for-each-ref", + "--format=%(refname) %(objectname)", + "refs/bisect/", + ], + )?; + let stamp = format!("{active}:{original}:{original_tip}:{log}:{expected}:{current}:{refs}:{status}:{bad_term}:{good_term}:{}", state_file(dir, "HEAD")?); + let token = git2::Oid::hash_object(git2::ObjectType::Blob, stamp.as_bytes())?.to_string(); + Ok(BisectState { + active, + token, + original, + original_tip, + current, + subject, + expected, + good_term, + bad_term, + remaining: remaining.min(10000), + remaining_truncated: remaining > 10000, + range_error, + culprit, + ambiguous, + no_checkout, + clean, + log, + }) + } + + pub fn bisect_start(&self, good: &str, bad: &str, token: &str) -> Result { + let before = self.bisect_state()?; + if before.token != token { + return Err(Error::Other( + "repository changed; refresh the bisect review".into(), + )); + } + if before.active || self.operation_in_progress().is_some() { + return Err(Error::Other( + "finish the current Git operation before starting bisect".into(), + )); + } + if !before.clean { + return Err(Error::Other( + "commit or stash working-tree/index changes before bisect".into(), + )); + } + let fresh = self.git2_owned()?; + let good = fresh.revparse_single(good)?.peel_to_commit()?.id(); + let bad = fresh.revparse_single(bad)?.peel_to_commit()?.id(); + if good == bad || !fresh.graph_descendant_of(bad, good)? { + return Err(Error::Other( + "the good revision must be an earlier ancestor of the bad revision".into(), + )); + } + let out = git( + &self.path, + &["bisect", "start", &bad.to_string(), &good.to_string(), "--"], + )?; + Ok(BisectOutcome { + success: out.status.success(), + output: text(&out), + state: self.bisect_state()?, + }) + } + + pub fn bisect_action(&self, action: BisectAction, token: &str) -> Result { + let before = self.bisect_state()?; + if !before.active { + return Err(Error::Other("no bisect session is active".into())); + } + if before.token != token { + return Err(Error::Other( + "bisect changed externally; refresh before rating a revision".into(), + )); + } + if !before.clean { + return Err(Error::Other( + "commit or stash test edits before bisect changes the checkout".into(), + )); + } + if self + .operation_in_progress() + .is_some_and(|op| op != "bisect") + { + return Err(Error::Other( + "finish the other Git operation before continuing bisect".into(), + )); + } + let out = if matches!(action, BisectAction::Reset) { + // Never use checkout/reset --force. Git also protects the original + // branch if another worktree has checked it out while we were testing. + git(&self.path, &["bisect", "reset"])? + } else { + if before.culprit.is_some() || before.ambiguous { + return Err(Error::Other( + "bisect has finished; review the result and reset".into(), + )); + } + if !before.expected.is_empty() && before.current != before.expected { + return Err(Error::Other("HEAD no longer matches Git's bisect selection; restore that checkout before rating".into())); + } + let term = match action { + BisectAction::Good => &before.good_term, + BisectAction::Bad => &before.bad_term, + BisectAction::Skip => "skip", + BisectAction::Reset => unreachable!(), + }; + if term.starts_with('-') + || !term + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(Error::Other("unsupported external bisect term".into())); + } + git(&self.path, &["bisect", term, &before.current])? + }; + Ok(BisectOutcome { + success: out.status.success(), + output: text(&out), + state: self.bisect_state()?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::interchange::InterchangeScratch; + fn fixture() -> (InterchangeScratch, Repo, Vec) { + let scratch = InterchangeScratch::new().unwrap(); + let repo = git2::Repository::init(&scratch.0).unwrap(); + let mut cfg = repo.config().unwrap(); + cfg.set_str("user.name", "Bisect Tester").unwrap(); + cfg.set_str("user.email", "bisect@example.test").unwrap(); + cfg.set_bool("commit.gpgsign", false).unwrap(); + cfg.set_str("core.hooksPath", "/dev/null").unwrap(); + let mut commits = vec![]; + for n in 0..8 { + fs::write(scratch.0.join("number"), format!("{n}\n")).unwrap(); + checked(&scratch.0, &["add", "."]).unwrap(); + checked(&scratch.0, &["commit", "-m", &format!("step {n}")]).unwrap(); + commits.push(checked(&scratch.0, &["rev-parse", "HEAD"]).unwrap()); + } + let handle = Repo::discover(&scratch.0).unwrap(); + (scratch, handle, commits) + } + #[test] + fn finds_culprit_and_restores_original_branch() { + let (_s, repo, commits) = fixture(); + let original = checked(&repo.path, &["symbolic-ref", "HEAD"]).unwrap(); + let mut state = repo + .bisect_start( + &commits[0], + &commits[7], + &repo.bisect_state().unwrap().token, + ) + .unwrap() + .state; + assert!(state.active && state.remaining > 0); + for _ in 0..10 { + if state.culprit.is_some() { + break; + } + let n = commits.iter().position(|c| c == &state.current).unwrap(); + state = repo + .bisect_action( + if n >= 4 { + BisectAction::Bad + } else { + BisectAction::Good + }, + &state.token, + ) + .unwrap() + .state; + } + assert_eq!(state.culprit, Some(commits[4].clone()), "{}", state.log); + assert!( + !repo + .bisect_action(BisectAction::Reset, &state.token) + .unwrap() + .state + .active + ); + assert_eq!( + checked(&repo.path, &["symbolic-ref", "HEAD"]).unwrap(), + original + ); + assert_eq!( + checked(&repo.path, &["rev-parse", "HEAD"]).unwrap(), + commits[7] + ); + } + #[test] + fn external_session_stale_ratings_dirty_reset_and_skips() { + let (_s, repo, commits) = fixture(); + checked( + &repo.path, + &["bisect", "start", &commits[7], &commits[0], "--"], + ) + .unwrap(); + let state = repo.bisect_state().unwrap(); + assert!(state.active); + checked(&repo.path, &["bisect", "good"]).unwrap(); + assert!(repo.bisect_action(BisectAction::Bad, &state.token).is_err()); + let fresh = repo.bisect_state().unwrap(); + fs::write(repo.path.join("number"), "test edits\n").unwrap(); + assert!(repo + .bisect_action(BisectAction::Reset, &fresh.token) + .is_err()); + assert_eq!( + fs::read_to_string(repo.path.join("number")).unwrap(), + "test edits\n" + ); + checked(&repo.path, &["restore", "number"]).unwrap(); + let mut state = repo.bisect_state().unwrap(); + for _ in 0..10 { + if state.ambiguous || state.culprit.is_some() { + break; + } + state = repo + .bisect_action(BisectAction::Skip, &state.token) + .unwrap() + .state; + } + assert!(state.ambiguous, "{}", state.log); + assert!( + repo.bisect_action(BisectAction::Reset, &state.token) + .unwrap() + .success + ); + } + #[test] + fn linked_worktree_and_external_no_checkout_terms() { + let (scratch, repo, commits) = fixture(); + let link = scratch.0.join("linked"); + checked( + &repo.path, + &["worktree", "add", "-b", "linked", link.to_str().unwrap()], + ) + .unwrap(); + let linked = Repo::discover(&link).unwrap(); + checked( + &link, + &[ + "bisect", + "start", + "--no-checkout", + "--term-good=old", + "--term-bad=new", + &commits[7], + &commits[0], + "--", + ], + ) + .unwrap(); + let state = linked.bisect_state().unwrap(); + assert!(state.active && state.no_checkout); + assert!(!repo.bisect_state().unwrap().active); + assert_eq!(state.good_term, "old"); + assert!( + linked + .bisect_action(BisectAction::Good, &state.token) + .unwrap() + .success + ); + let state = linked.bisect_state().unwrap(); + assert!( + linked + .bisect_action(BisectAction::Reset, &state.token) + .unwrap() + .success + ); + } +} diff --git a/crates/strand-core/src/gitflow.rs b/crates/strand-core/src/gitflow.rs new file mode 100644 index 0000000..3573d99 --- /dev/null +++ b/crates/strand-core/src/gitflow.rs @@ -0,0 +1,809 @@ +//! Opt-in Git-flow AVH orchestration. No work is added to ordinary snapshots. +use crate::{Error, Repo, Result}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeMap, + fs, + hash::{Hash, Hasher}, + io::{Read, Write}, + path::Path, + process::Stdio, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FlowConfig { + pub production: String, + pub develop: String, + pub feature: String, + pub release: String, + pub hotfix: String, + pub version_tag: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlowState { + pub enabled: bool, + pub config: FlowConfig, + pub options: BTreeMap, + pub branches: BTreeMap, + pub current: String, + pub head: String, + pub operation: Option, + pub clean: bool, + pub conflicts: bool, + pub token: String, +} +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FlowKind { + Feature, + Release, + Hotfix, +} +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FlowAction { + Start, + Finish, + ContinueMerge, + AbortMerge, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlowPlan { + pub kind: FlowKind, + pub action: FlowAction, + pub name: String, + pub token: String, + pub args: Vec, + pub steps: Vec, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlowTool { + pub available: bool, + pub version: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlowOutcome { + pub success: bool, + pub output: String, + pub state: FlowState, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::interchange::InterchangeScratch; + fn git(path: &Path, args: &[&str]) -> String { + let (ok, output) = run( + path, + &args.iter().map(|s| s.to_string()).collect::>(), + |_| {}, + ) + .unwrap(); + assert!(ok, "{args:?}: {output}"); + output.trim().into() + } + fn fixture() -> (InterchangeScratch, Repo, FlowConfig) { + let scratch = InterchangeScratch::new().unwrap(); + let raw = git2::Repository::init(&scratch.0).unwrap(); + let mut cfg = raw.config().unwrap(); + for (key, value) in [ + ("user.name", "Flow Tester"), + ("user.email", "flow@example.test"), + ("core.hooksPath", "/dev/null"), + ("core.autocrlf", "false"), + ] { + cfg.set_str(key, value).unwrap(); + } + cfg.set_bool("commit.gpgsign", false).unwrap(); + cfg.set_bool("tag.gpgsign", false).unwrap(); + fs::write(scratch.0.join("file"), "base\n").unwrap(); + git(&scratch.0, &["add", "."]); + git(&scratch.0, &["commit", "-m", "base"]); + git(&scratch.0, &["branch", "-M", "main"]); + git(&scratch.0, &["branch", "develop"]); + let repo = Repo::discover(&scratch.0).unwrap(); + let cfg = FlowConfig { + production: "main".into(), + develop: "develop".into(), + feature: "feature/".into(), + release: "release/".into(), + hotfix: "hotfix/".into(), + version_tag: "v".into(), + }; + (scratch, repo, cfg) + } + fn enable(repo: &Repo, cfg: FlowConfig) { + repo.configure_gitflow(cfg, true, &repo.gitflow_state().unwrap().token) + .unwrap(); + } + fn execute(repo: &Repo, kind: FlowKind, action: FlowAction, name: &str) -> FlowOutcome { + repo.run_gitflow(repo.plan_gitflow(kind, action, name).unwrap(), |_| {}) + .unwrap() + } + #[test] + fn config_is_opt_in_atomic_and_preserves_external_settings() { + let (_s, repo, cfg) = fixture(); + assert!(!repo.gitflow_state().unwrap().enabled); + assert!(repo + .plan_gitflow(FlowKind::Feature, FlowAction::Start, "one") + .is_err()); + let stale = repo.gitflow_state().unwrap(); + git(&repo.path, &["config", "gitflow.origin", "upstream"]); + assert!(repo + .configure_gitflow(cfg.clone(), true, &stale.token) + .is_err()); + enable(&repo, cfg.clone()); + assert_eq!(git(&repo.path, &["config", "gitflow.origin"]), "upstream"); + assert_eq!(repo.gitflow_state().unwrap().config, cfg); + let token = repo.gitflow_state().unwrap().token; + let lock = repo.git_dir().join("config.lock"); + fs::write(&lock, "external lock").unwrap(); + assert!(repo.configure_gitflow(cfg.clone(), false, &token).is_err()); + assert_eq!(fs::read_to_string(&lock).unwrap(), "external lock"); + fs::remove_file(lock).unwrap(); + repo.configure_gitflow(cfg, false, &token).unwrap(); + assert!(!repo.gitflow_state().unwrap().enabled); + assert_eq!( + git(&repo.path, &["config", "gitflow.branch.master"]), + "main" + ); + } + #[test] + #[ignore = "requires installed Git-flow AVH; run with --include-ignored"] + fn starts_and_finishes_all_three_kinds_without_publication_or_branch_deletion() { + assert!(detect().unwrap().available); + let (_s, repo, cfg) = fixture(); + enable(&repo, cfg); + // Even existing push/fetch preferences must not make the reviewed local + // workflow publish, fetch, or delete branches. + for kind in ["feature", "release", "hotfix"] { + for flag in ["push", "fetch"] { + git( + &repo.path, + &["config", &format!("gitflow.{kind}.finish.{flag}"), "true"], + ); + } + } + for flag in ["pushproduction", "pushdevelop", "pushtag"] { + git( + &repo.path, + &["config", &format!("gitflow.release.finish.{flag}"), "true"], + ); + } + for (kind, label) in [ + (FlowKind::Feature, "feature"), + (FlowKind::Release, "release"), + (FlowKind::Hotfix, "hotfix"), + ] { + let started = execute(&repo, kind, FlowAction::Start, label); + assert!(started.success, "{}", started.output); + fs::write(repo.path.join(label), "work\n").unwrap(); + git(&repo.path, &["add", label]); + git(&repo.path, &["commit", "-m", label]); + let tip = git(&repo.path, &["rev-parse", "HEAD"]); + let finished = execute(&repo, kind, FlowAction::Finish, label); + assert!(finished.success, "{}", finished.output); + assert!(finished.state.clean && finished.state.operation.is_none()); + assert_eq!( + git(&repo.path, &["rev-parse", &format!("{label}/{label}")]), + tip + ); + git( + &repo.path, + &["merge-base", "--is-ancestor", &tip, "develop"], + ); + if kind != FlowKind::Feature { + git(&repo.path, &["merge-base", "--is-ancestor", &tip, "main"]); + assert_eq!( + git(&repo.path, &["cat-file", "-t", &format!("v{label}")]), + "tag" + ); + } + } + } + #[test] + #[ignore = "requires installed Git-flow AVH; run with --include-ignored"] + fn external_start_stale_review_and_conflict_continue_or_abort() { + let (_s, repo, cfg) = fixture(); + enable(&repo, cfg); + git(&repo.path, &["flow", "feature", "start", "external"]); + fs::write(repo.path.join("file"), "feature\n").unwrap(); + git(&repo.path, &["commit", "-am", "feature edit"]); + let stale = repo + .plan_gitflow(FlowKind::Feature, FlowAction::Finish, "external") + .unwrap(); + git(&repo.path, &["checkout", "develop"]); + fs::write(repo.path.join("file"), "develop\n").unwrap(); + git(&repo.path, &["commit", "-am", "develop edit"]); + assert!(repo.run_gitflow(stale, |_| {}).is_err()); + let paused = execute(&repo, FlowKind::Feature, FlowAction::Finish, "external"); + assert!(!paused.success && paused.state.conflicts); + assert!(repo + .plan_gitflow(FlowKind::Feature, FlowAction::ContinueMerge, "external") + .is_err()); + let aborted = execute(&repo, FlowKind::Feature, FlowAction::AbortMerge, "external"); + assert!(aborted.success && aborted.state.clean); + let paused = execute(&repo, FlowKind::Feature, FlowAction::Finish, "external"); + assert!(!paused.success); + fs::write(repo.path.join("file"), "resolved\n").unwrap(); + git(&repo.path, &["add", "file"]); + assert!( + execute( + &repo, + FlowKind::Feature, + FlowAction::ContinueMerge, + "external" + ) + .success + ); + let done = execute(&repo, FlowKind::Feature, FlowAction::Finish, "external"); + assert!(done.success, "{}", done.output); + assert_eq!( + fs::read_to_string(repo.path.join("file")).unwrap(), + "resolved\n" + ); + assert!(repo + .plan_gitflow( + FlowKind::Feature, + FlowAction::Start, + "bad'$(touch injected)" + ) + .is_err()); + assert!(repo + .plan_gitflow(FlowKind::Feature, FlowAction::Finish, "exter") + .is_err()); + } + + #[test] + #[ignore = "requires installed Git-flow AVH; run with --include-ignored"] + fn release_recovery_preserves_completed_production_merge_and_tag() { + let (_s, repo, cfg) = fixture(); + enable(&repo, cfg); + assert!(execute(&repo, FlowKind::Release, FlowAction::Start, "partial").success); + fs::write(repo.path.join("file"), "release\n").unwrap(); + git(&repo.path, &["commit", "-am", "release edit"]); + git(&repo.path, &["checkout", "develop"]); + fs::write(repo.path.join("file"), "develop\n").unwrap(); + git(&repo.path, &["commit", "-am", "develop edit"]); + let paused = execute(&repo, FlowKind::Release, FlowAction::Finish, "partial"); + assert!( + !paused.success && paused.state.conflicts, + "{}", + paused.output + ); + let tag = git(&repo.path, &["rev-parse", "vpartial"]); + let main = git(&repo.path, &["rev-parse", "main"]); + let abort = repo + .plan_gitflow(FlowKind::Release, FlowAction::AbortMerge, "partial") + .unwrap(); + fs::write( + repo.path.join("file"), + "new external resolution with different length\n", + ) + .unwrap(); + assert!(repo.run_gitflow(abort, |_| {}).is_err()); + assert!(execute(&repo, FlowKind::Release, FlowAction::AbortMerge, "partial").success); + assert_eq!(git(&repo.path, &["rev-parse", "main"]), main); + assert_eq!(git(&repo.path, &["rev-parse", "vpartial"]), tag); + assert!(!execute(&repo, FlowKind::Release, FlowAction::Finish, "partial").success); + fs::write(repo.path.join("file"), "resolved\n").unwrap(); + git(&repo.path, &["add", "file"]); + assert!( + execute( + &repo, + FlowKind::Release, + FlowAction::ContinueMerge, + "partial" + ) + .success + ); + let done = execute(&repo, FlowKind::Release, FlowAction::Finish, "partial"); + assert!(done.success, "{}", done.output); + assert_eq!(git(&repo.path, &["rev-parse", "vpartial"]), tag); + } +} + +// Drain both pipes with bounded queues and transcripts. Git-flow writes normal +// progress to stdout as well as stderr. Updates are coalesced before IPC. +fn run(cwd: &Path, args: &[String], mut progress: impl FnMut(String)) -> Result<(bool, String)> { + let mut child = crate::git_command() + .current_dir(cwd) + .args(crate::GIT_SAFE_CONFIG) + .args(args) + .env("GIT_EDITOR", "true") + .env("GIT_MERGE_AUTOEDIT", "no") + .env("GIT_TERMINAL_PROMPT", "0") + .env("LC_ALL", "C") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + let (tx, rx) = std::sync::mpsc::sync_channel::>(16); + let pipes: Vec> = vec![ + Box::new(child.stdout.take().unwrap()), + Box::new(child.stderr.take().unwrap()), + ]; + for mut pipe in pipes { + let tx = tx.clone(); + std::thread::spawn(move || { + let mut buf = [0; 4096]; + loop { + match pipe.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + if tx.send(buf[..n].to_vec()).is_err() { + break; + } + } + } + } + }); + } + drop(tx); + let mut output = String::new(); + let mut last = std::time::Instant::now(); + while let Ok(chunk) = rx.recv() { + output.push_str(&String::from_utf8_lossy(&chunk)); + if output.len() > 65536 { + let mut cut = output.len() - 65536; + while !output.is_char_boundary(cut) { + cut += 1; + } + output.drain(..cut); + } + if last.elapsed().as_millis() >= 100 { + progress(output.clone()); + last = std::time::Instant::now(); + } + } + let success = child.wait()?.success(); + progress(output.clone()); + Ok((success, output)) +} + +pub fn detect() -> Result { + // A repository-local alias must never be used for discovery. Actual Git-flow + // executables take precedence over aliases once the extension is installed. + let scratch = crate::interchange::InterchangeScratch::new()?; + let (success, version) = run(&scratch.0, &["flow".into(), "version".into()], |_| {})?; + Ok(FlowTool { + available: success && version.contains("AVH Edition"), + version: version.trim().into(), + }) +} +fn safe_name(value: &str) -> bool { + !value.is_empty() + && !value.starts_with(['-', '.']) + && value + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"-._/".contains(&c)) + && git2::Reference::is_valid_name(&format!("refs/heads/{value}")) +} +fn validate_config(config: &FlowConfig, state: &FlowState) -> Result<()> { + if config.production == config.develop + || [&config.production, &config.develop] + .iter() + .any(|n| !safe_name(n) || !state.branches.contains_key(*n)) + { + return Err(Error::Other( + "select distinct, existing production and develop branches".into(), + )); + } + let prefixes = [&config.feature, &config.release, &config.hotfix]; + for (i, prefix) in prefixes.iter().enumerate() { + if !prefix.ends_with('/') + || !safe_name(&format!("{prefix}example")) + || prefixes + .iter() + .enumerate() + .any(|(j, p)| i != j && prefix.starts_with(p.as_str())) + { + return Err(Error::Other( + "use distinct, non-overlapping branch prefixes ending in /".into(), + )); + } + if [&config.production, &config.develop] + .iter() + .any(|b| b.starts_with(prefix.as_str())) + { + return Err(Error::Other( + "base branches cannot use a workflow prefix".into(), + )); + } + } + if !safe_name(&format!("{}example", config.version_tag)) { + return Err(Error::Other("invalid version tag prefix".into())); + } + Ok(()) +} + +impl Repo { + pub fn gitflow_state(&self) -> Result { + let repo = self.git2_owned()?; + let cfg = repo.config()?.snapshot()?; + let get = + |key: &str, fallback: &str| cfg.get_string(key).unwrap_or_else(|_| fallback.into()); + let config = FlowConfig { + production: get("gitflow.branch.master", ""), + develop: get("gitflow.branch.develop", ""), + feature: get("gitflow.prefix.feature", "feature/"), + release: get("gitflow.prefix.release", "release/"), + hotfix: get("gitflow.prefix.hotfix", "hotfix/"), + version_tag: get("gitflow.prefix.versiontag", ""), + }; + let mut options = BTreeMap::new(); + let mut entries = cfg.entries(Some("^gitflow\\."))?; + while let Some(entry) = entries.next() { + let entry = entry?; + if options.len() >= 1000 || entry.value_bytes().len() > 16384 { + return Err(Error::Other( + "Git-flow configuration is too large to inspect".into(), + )); + } + options.insert( + entry + .name() + .ok_or_else(|| Error::Other("Git-flow key is not UTF-8".into()))? + .into(), + entry + .value() + .ok_or_else(|| Error::Other("Git-flow value is not UTF-8".into()))? + .into(), + ); + } + let mut branches = BTreeMap::new(); + let mut refs = BTreeMap::new(); + for r in repo.references()? { + let r = r?; + if refs.len() >= 20000 { + return Err(Error::Other("too many refs for Git-flow review".into())); + } + if let (Some(name), Some(oid)) = (r.name(), r.target()) { + refs.insert(name.to_owned(), oid.to_string()); + if let Some(name) = name.strip_prefix("refs/heads/") { + branches.insert(name.into(), oid.to_string()); + } + } + } + let head_ref = repo.head()?; + let head = head_ref.peel_to_commit()?.id().to_string(); + let current = if head_ref.is_branch() { + head_ref.shorthand().unwrap_or("").to_owned() + } else { + String::new() + }; + let statuses = repo.statuses(Some( + git2::StatusOptions::new() + .include_untracked(true) + .recurse_untracked_dirs(true), + ))?; + let mut hash = std::collections::hash_map::DefaultHasher::new(); + for status in &statuses { + status.path_bytes().hash(&mut hash); + status.status().bits().hash(&mut hash); + if let Some(path) = status.path() { + if let Ok(meta) = fs::symlink_metadata(self.path.join(path)) { + meta.len().hash(&mut hash); + meta.modified().ok().hash(&mut hash); + } + } + } + for marker in ["HEAD", "MERGE_HEAD", "MERGE_MSG", "ORIG_HEAD", "index"] { + let path = self.git_dir().join(marker); + if let Ok(meta) = fs::metadata(&path) { + meta.len().hash(&mut hash); + meta.modified().ok().hash(&mut hash); + } + } + let enabled = cfg.get_bool("strand.gitflow.enabled").unwrap_or(false); + enabled.hash(&mut hash); + refs.hash(&mut hash); + options.hash(&mut hash); + current.hash(&mut hash); + head.hash(&mut hash); + let operation = self.operation_in_progress(); + operation.hash(&mut hash); + Ok(FlowState { + enabled, + config, + options, + branches, + current, + head, + operation, + clean: statuses.is_empty(), + conflicts: repo.index()?.has_conflicts(), + token: format!("{:016x}", hash.finish()), + }) + } + + pub fn configure_gitflow( + &self, + config: FlowConfig, + enabled: bool, + token: &str, + ) -> Result { + let before = self.gitflow_state()?; + if before.token != token { + return Err(Error::Other( + "repository/config changed externally; refresh before saving".into(), + )); + } + if before.operation.is_some() { + return Err(Error::Other( + "finish the active Git operation before changing Git-flow settings".into(), + )); + } + if enabled { + validate_config(&config, &before)?; + } + let repo = self.git2_owned()?; + if repo + .config()? + .get_bool("extensions.worktreeConfig") + .unwrap_or(false) + { + return Err(Error::Other( + "manage per-worktree Git-flow configuration externally".into(), + )); + } + let config_path = self.gix.common_dir().join("config"); + if fs::symlink_metadata(&config_path)?.file_type().is_symlink() { + return Err(Error::Other( + "manage symlinked Git config externally".into(), + )); + } + let lock_path = config_path.with_extension("lock"); + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&lock_path)?; + struct Lock(std::path::PathBuf); + impl Drop for Lock { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } + } + let _lock = Lock(lock_path.clone()); + if self.gitflow_state()?.token != token { + return Err(Error::Other( + "repository/config changed externally; refresh before saving".into(), + )); + } + if fs::metadata(&config_path)?.len() > 4 * 1024 * 1024 { + return Err(Error::Other( + "Git config exceeds the 4 MiB editing limit".into(), + )); + } + let bytes = fs::read(&config_path)?; + file.write_all(&bytes)?; + file.sync_all()?; + drop(file); + fs::set_permissions(&lock_path, fs::metadata(&config_path)?.permissions())?; + let mut local = git2::Config::open(&lock_path)?; + local.set_bool("strand.gitflow.enabled", enabled)?; + if enabled { + for (key, value) in [ + ("gitflow.branch.master", &config.production), + ("gitflow.branch.develop", &config.develop), + ("gitflow.prefix.feature", &config.feature), + ("gitflow.prefix.release", &config.release), + ("gitflow.prefix.hotfix", &config.hotfix), + ("gitflow.prefix.versiontag", &config.version_tag), + ] { + local.set_str(key, value)?; + } + for (key, value) in [ + ("gitflow.prefix.support", "support/"), + ("gitflow.prefix.bugfix", "bugfix/"), + ] { + if repo.config()?.get_string(key).is_err() { + local.set_str(key, value)?; + } + } + } + drop(local); + fs::rename(&lock_path, &config_path)?; + self.gitflow_state() + } + + pub fn plan_gitflow(&self, kind: FlowKind, action: FlowAction, name: &str) -> Result { + let state = self.gitflow_state()?; + if !state.enabled { + return Err(Error::Other( + "enable Git-flow for this repository first".into(), + )); + } + let mut steps = vec![format!( + "Current checkout: {} ({})", + state.current, state.head + )]; + let mut args = vec![]; + match action { + FlowAction::ContinueMerge | FlowAction::AbortMerge => { + if state.operation.as_deref() != Some("merge") { + return Err(Error::Other( + "no merge is active; refresh and review the next workflow step".into(), + )); + } + if action == FlowAction::ContinueMerge && state.conflicts { + return Err(Error::Other( + "resolve and stage every conflict before continuing the merge".into(), + )); + } + args.extend([ + "merge".into(), + if action == FlowAction::ContinueMerge { + "--continue" + } else { + "--abort" + } + .into(), + ]); + steps.push(if action == FlowAction::ContinueMerge { "Commit the staged merge resolution, then review Finish again to complete any remaining Git-flow stages." } else { "Abort only the current merge. Earlier completed merges/tags remain; this is not a rollback of the entire workflow." }.into()); + } + FlowAction::Start | FlowAction::Finish => { + validate_config(&state.config, &state)?; + if !state.clean || state.operation.is_some() || state.current.is_empty() { + return Err(Error::Other( + "start/finish requires a clean branch checkout and no active Git operation" + .into(), + )); + } + if !safe_name(name) { + return Err(Error::Other( + "use an exact workflow name containing letters, digits, /, -, _ or ." + .into(), + )); + } + if self.git_dir().join("gitflow_config").exists() { + return Err(Error::Other( + "migrate legacy gitflow_config with Git-flow before using this dialog" + .into(), + )); + } + let (kind_name, prefix, base) = match kind { + FlowKind::Feature => ("feature", &state.config.feature, &state.config.develop), + FlowKind::Release => ("release", &state.config.release, &state.config.develop), + FlowKind::Hotfix => ("hotfix", &state.config.hotfix, &state.config.production), + }; + let branch = format!("{prefix}{name}"); + if state + .options + .get(&format!("gitflow.branch.{branch}.base")) + .is_some_and(|b| b != base) + { + return Err(Error::Other("this workflow has a custom base; finish it with the external Git-flow tool".into())); + } + // AVH uses eval for tag message/key arguments; never let arbitrary + // message text cross that shell boundary. Generated copy is reviewed. + if state.options.iter().any(|(k, v)| { + k.ends_with(".signingkey") + && (v.starts_with('-') || v.contains(['\'', '\r', '\n'])) + }) { + return Err(Error::Other( + "unsupported Git-flow signing key; configure it externally".into(), + )); + } + args.extend([ + "flow".into(), + kind_name.into(), + if action == FlowAction::Start { + "start" + } else { + "finish" + } + .into(), + "--nofetch".into(), + ]); + if action == FlowAction::Start { + if state.branches.contains_key(&branch) { + return Err(Error::Other( + "workflow branch already exists; select Finish or a new name".into(), + )); + } + steps.push(format!( + "Create and check out {branch} from {base} ({})", + state.branches[base] + )); + args.extend([name.into(), base.clone()]); + } else { + let tip = state.branches.get(&branch).ok_or_else(|| { + Error::Other("exact workflow branch does not exist".into()) + })?; + args.extend(["--nopush".into(), "--keep".into(), "--nosquash".into()]); + if kind == FlowKind::Feature { + args.push("--norebase".into()); + } else { + args.extend([ + "--nonotag".into(), + "--nonobackmerge".into(), + format!("--message=Finish {kind_name} {name}"), + "--messagefile=".into(), + format!("--tagname={name}"), + ]); + if kind == FlowKind::Release { + args.extend([ + "--nopushproduction".into(), + "--nopushdevelop".into(), + "--nopushtag".into(), + "--nonodevelopmerge".into(), + "--noff-master".into(), + ]); + } + } + args.push(name.into()); + let repo = self.git2_owned()?; + let destinations = if kind == FlowKind::Feature { + vec![&state.config.develop] + } else { + vec![&state.config.production] + }; + for dest in destinations { + let dest_tip = &state.branches[dest]; + let source = git2::Oid::from_str(tip)?; + let destination = git2::Oid::from_str(dest_tip)?; + let merged = source == destination + || repo.graph_descendant_of(destination, source)?; + steps.push(format!( + "{branch} ({tip}) → {dest} ({dest_tip}){}", + if merged { + " — source already merged; AVH skips completed stages" + } else { + "" + } + )); + } + if kind != FlowKind::Feature { + let tag = format!("{}{name}", state.config.version_tag); + let existing = repo + .find_reference(&format!("refs/tags/{tag}")) + .ok() + .and_then(|r| r.target()) + .map(|o| o.to_string()); + steps.push(format!("Tag {tag}: {}. Annotation: Finish {kind_name} {name}. Configured signing is honored.", existing.map(|oid| format!("already exists at {oid}; Git-flow validates it before resuming")).unwrap_or_else(|| "create on production merge".into()))); + steps.push(format!("Back-merge the resulting production tag {tag} into {} (currently {}). Already completed merges are skipped.", state.config.develop, state.branches[&state.config.develop])); + } + steps.push(format!("Retain {branch} locally and remotely. No fetch or push. Git and Git-flow hooks still run.")); + } + } + } + Ok(FlowPlan { + kind, + action, + name: name.into(), + token: state.token, + args, + steps, + }) + } + + pub fn run_gitflow(&self, plan: FlowPlan, progress: impl FnMut(String)) -> Result { + let current = self.plan_gitflow(plan.kind, plan.action, &plan.name)?; + if current.token != plan.token || current.args != plan.args || current.steps != plan.steps { + return Err(Error::Other( + "Git refs, checkout or configuration changed; review the operation again".into(), + )); + } + if matches!(plan.action, FlowAction::Start | FlowAction::Finish) { + let tool = detect()?; + if !tool.available { + return Err(Error::Other(format!( + "Git-flow AVH is required: {}", + tool.version + ))); + } + if self.gitflow_state()?.token != plan.token { + return Err(Error::Other( + "repository changed during tool detection; review again".into(), + )); + } + } + let (success, output) = run(&self.path, ¤t.args, progress)?; + Ok(FlowOutcome { + success, + output, + state: self.gitflow_state()?, + }) + } +} diff --git a/crates/strand-core/src/history.rs b/crates/strand-core/src/history.rs index 1d0ae78..f4c90dc 100644 --- a/crates/strand-core/src/history.rs +++ b/crates/strand-core/src/history.rs @@ -370,6 +370,7 @@ impl Repo { .operation_in_progress() .ok_or_else(|| Error::Other("no operation in progress to continue".into()))?; let cmd = match op.as_str() { + "mailbox" => "am", "rebase" => "rebase", "cherry-pick" => "cherry-pick", "revert" => "revert", @@ -397,7 +398,7 @@ impl Repo { /// suppression). Same pause-aware mapping. fn run_sequencer_env(&self, args: &[&str], envs: &[(&str, &str)]) -> Result { match run_git_env(&self.path, args, envs) { - Ok(_) => Ok(self.operation_in_progress().is_some()), + Ok(_) => Ok(self.operation_in_progress().is_some_and(|op| op != "bisect")), Err(e) => { // A conflict is the expected paused outcome. Git can also // leave CHERRY_PICK_HEAD/REVERT_HEAD behind after a *real* @@ -429,6 +430,7 @@ impl Repo { .operation_in_progress() .ok_or_else(|| Error::Other("no operation in progress to abort".into()))?; let cmd = match op.as_str() { + "mailbox" => "am", "rebase" => "rebase", "cherry-pick" => "cherry-pick", "revert" => "revert", diff --git a/crates/strand-core/src/interchange.rs b/crates/strand-core/src/interchange.rs new file mode 100644 index 0000000..a5ef419 --- /dev/null +++ b/crates/strand-core/src/interchange.rs @@ -0,0 +1,942 @@ +//! Explicit, previewed patch/mailbox/bundle interchange. Reads stay off snapshots. + +use crate::{Error, Repo, Result}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeSet, + fs, + io::{Read, Write}, + path::{Path, PathBuf}, + process::{Output, Stdio}, +}; + +const MAX_PATCH: u64 = 32 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PatchTarget { + Worktree, + Index, + Both, + Mailbox, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PatchPreview { + pub token: String, + pub paths: Vec, + pub messages: Vec, + pub valid: bool, + pub validation: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MailboxState { + pub token: String, + pub current: String, + pub total: String, + pub author: String, + pub conflicts: bool, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MailboxAction { + Continue, + Skip, + Abort, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InterchangeOutcome { + pub success: bool, + pub paused: bool, + pub output: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BundleRef { + pub oid: String, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BundlePreview { + pub token: String, + pub refs: Vec, + pub prerequisites: Vec, + pub valid: bool, + pub validation: String, +} + +// Private temporary directory, independent of filenames from imported content. +pub(crate) struct InterchangeScratch(pub PathBuf); +impl InterchangeScratch { + pub(crate) fn new() -> Result { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + for _ in 0..100 { + let p = std::env::temp_dir().join(format!( + "strand-interchange-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + match fs::create_dir(&p) { + Ok(()) => return Ok(Self(p)), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e.into()), + } + } + Err(Error::Other( + "cannot allocate interchange scratch directory".into(), + )) + } +} +impl Drop for InterchangeScratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn git(cwd: &Path, args: &[&str], stdin: Option<&Path>, index: Option<&Path>) -> Result { + let mut cmd = crate::git_command(); + cmd.current_dir(cwd) + .args(crate::GIT_SAFE_CONFIG) + .args(args) + .env("GIT_EDITOR", "true") + .env("GIT_TERMINAL_PROMPT", "0") + .stdin(Stdio::null()); + if let Some(p) = stdin { + cmd.stdin(fs::File::open(p)?); + } + if let Some(p) = index { + cmd.env("GIT_INDEX_FILE", p); + } + let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?; + fn drain(mut pipe: impl Read) -> std::io::Result> { + let mut captured = Vec::new(); + let mut buf = [0u8; 8192]; + loop { + let n = pipe.read(&mut buf)?; + if n == 0 { + break; + } + let keep = n.min((1024 * 1024usize).saturating_sub(captured.len())); + captured.extend_from_slice(&buf[..keep]); + } + Ok(captured) + } + let stdout = child.stdout.take().expect("piped stdout"); + let stderr = child.stderr.take().expect("piped stderr"); + let reader = std::thread::spawn(move || drain(stdout)); + let stderr = drain(stderr)?; + let status = child.wait()?; + let stdout = reader + .join() + .map_err(|_| Error::Other("Git output reader failed".into()))??; + Ok(Output { + status, + stdout, + stderr, + }) +} + +fn diagnostic(out: &Output) -> String { + let mut bytes = out + .stdout + .iter() + .chain(out.stderr.iter()) + .take(64 * 1024) + .copied() + .collect::>(); + if out.stdout.len() + out.stderr.len() > bytes.len() { + bytes.extend_from_slice(b"\n[output truncated]"); + } + String::from_utf8_lossy(&bytes).trim().to_owned() +} +fn checked(out: Output) -> Result { + if out.status.success() { + Ok(out) + } else { + Err(Error::Other(diagnostic(&out))) + } +} +fn utf8_path(p: &Path) -> Result<&str> { + p.to_str() + .ok_or_else(|| Error::Other("path is not UTF-8".into())) +} +fn digest(bytes: &[u8]) -> Result { + Ok(git2::Oid::hash_object(git2::ObjectType::Blob, bytes)?.to_string()) +} +fn bounded_read(p: &Path, limit: u64) -> Result> { + let mut data = Vec::new(); + fs::File::open(p)?.take(limit + 1).read_to_end(&mut data)?; + if data.len() as u64 > limit { + return Err(Error::Other(format!( + "{} exceeds the {} MiB import limit", + p.display(), + limit / 1024 / 1024 + ))); + } + Ok(data) +} + +/// Concurrency stamp, not an authentication hash. Streams large bundle files. +fn file_stamp(p: &Path) -> Result { + use std::hash::Hasher; + let mut hash = std::collections::hash_map::DefaultHasher::new(); + let mut file = fs::File::open(p)?; + let mut buf = [0u8; 65536]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + hash.write(&buf[..n]); + } + Ok(format!("{:016x}", hash.finish())) +} + +impl Repo { + // Includes actual file bytes, because equal status rows do not mean equal content. + fn import_stamp(&self, paths: &[String], extra: &[u8]) -> Result { + let mut stamp = extra.to_vec(); + for name in [ + "HEAD", + "index", + "rebase-apply/next", + "rebase-apply/last", + "rebase-apply/info", + "rebase-apply/patch", + ] { + let p = self.git_dir().join(name); + if p.is_file() { + stamp.extend_from_slice(file_stamp(&p)?.as_bytes()); + } + } + stamp.extend_from_slice( + format!("{:?}", self.git2()?.head().ok().and_then(|h| h.target())).as_bytes(), + ); + for p in paths { + self.check_import_path(p)?; + stamp.extend_from_slice(p.as_bytes()); + let full = self.path.join(p); + if full.is_file() { + stamp.extend_from_slice(file_stamp(&full)?.as_bytes()); + } + } + digest(&stamp) + } + + /// Reject nonportable traversal, Git administrative paths, and symlink ancestors, + /// including dangling links and not-yet-created nested directories. + fn check_import_path(&self, path: &str) -> Result<()> { + if path.is_empty() || path.contains(['\\', ':', '\0']) || path.starts_with('/') { + return Err(Error::Other(format!("unsafe patch path: {path}"))); + } + let mut full = self.path.clone(); + for part in path.split('/') { + if part.is_empty() + || part == "." + || part == ".." + || part.eq_ignore_ascii_case(".git") + || part + .trim_end_matches([' ', '.']) + .eq_ignore_ascii_case(".git") + { + return Err(Error::Other(format!("unsafe patch path: {path}"))); + } + full.push(part); + match fs::symlink_metadata(&full) { + Ok(m) if m.file_type().is_symlink() => { + return Err(Error::Other(format!( + "patch path traverses a symlink: {path}" + ))) + } + Ok(_) => { + if !full.canonicalize()?.starts_with(self.path.canonicalize()?) { + return Err(Error::Other(format!( + "patch path escapes repository: {path}" + ))); + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + } + Ok(()) + } + + fn patch_paths(&self, bytes: &[u8]) -> Result> { + let diff = git2::Diff::from_buffer(bytes)?; + let mut paths = BTreeSet::new(); + for delta in diff.deltas() { + for file in [delta.old_file(), delta.new_file()] { + // Applying links can change containment of a later patch in a series. + if file.mode() == git2::FileMode::Link { + return Err(Error::Other( + "importing symlink patches is not supported; inspect and apply with Git" + .into(), + )); + } + if let Some(p) = file.path() { + let p = utf8_path(p)?; + self.check_import_path(p)?; + paths.insert(p.to_owned()); + } + } + } + if paths.is_empty() { + return Err(Error::Other("patch contains no affected paths".into())); + } + Ok(paths.into_iter().collect()) + } + + pub fn preview_patch_import(&self, source: &Path, target: PatchTarget) -> Result { + if !source.is_absolute() { + return Err(Error::Other("patch source must be an absolute path".into())); + } + let bytes = bounded_read(source, MAX_PATCH)?; + self.preview_patch_bytes(&bytes, target) + } + + fn preview_patch_bytes(&self, bytes: &[u8], target: PatchTarget) -> Result { + let scratch = InterchangeScratch::new()?; + let input = scratch.0.join("input"); + fs::write(&input, bytes)?; + let mut paths = BTreeSet::new(); + let mut messages = Vec::new(); + let mut valid = true; + let mut validation = String::new(); + if target == PatchTarget::Mailbox { + let maildir = scratch.0.join("mail"); + fs::create_dir(&maildir)?; + let outdir = format!("-o{}", utf8_path(&maildir)?); + let split = checked(git( + &self.path, + &["mailsplit", "-b", &outdir, "--", utf8_path(&input)?], + None, + None, + )?)?; + let count: usize = String::from_utf8_lossy(&split.stdout) + .trim() + .parse() + .map_err(|_| Error::Other("invalid mailbox count".into()))?; + if count == 0 || count > 1000 { + return Err(Error::Other("mailbox must contain 1–1000 patches".into())); + } + let index = scratch.0.join("index"); + checked(git(&self.path, &["read-tree", "HEAD"], None, Some(&index))?)?; + for n in 1..=count { + let mail = maildir.join(format!("{n:04}")); + let patch = scratch.0.join("patch"); + let message = scratch.0.join("message"); + let info = checked(git( + &self.path, + &["mailinfo", utf8_path(&message)?, utf8_path(&patch)?], + Some(&mail), + None, + )?)?; + messages.push(diagnostic(&info)); + paths.extend(self.patch_paths(&bounded_read(&patch, MAX_PATCH)?)?); + if valid { + let out = git( + &self.path, + &["apply", "--cached", "--", utf8_path(&patch)?], + None, + Some(&index), + )?; + if !out.status.success() { + valid = false; + validation = format!("Patch {n}: {}", diagnostic(&out)); + } + } + } + } else { + paths.extend(self.patch_paths(bytes)?); + let mut args = vec!["apply", "--check"]; + match target { + PatchTarget::Index => args.push("--cached"), + PatchTarget::Both => args.push("--index"), + _ => {} + } + args.extend(["--", utf8_path(&input)?]); + let out = git(&self.path, &args, None, None)?; + valid = out.status.success(); + validation = diagnostic(&out); + } + let paths: Vec<_> = paths.into_iter().collect(); + let token = + self.import_stamp(&paths, format!("{target:?}:{}", digest(bytes)?).as_bytes())?; + Ok(PatchPreview { + token, + paths, + messages, + valid, + validation, + }) + } + + pub fn import_patch( + &self, + source: &Path, + target: PatchTarget, + token: &str, + ) -> Result { + if !source.is_absolute() { + return Err(Error::Other("patch source must be an absolute path".into())); + } + if let Some(op) = self.operation_in_progress() { + return Err(Error::Other(format!( + "finish or abort {op} before importing" + ))); + } + let bytes = bounded_read(source, MAX_PATCH)?; + let preview = self.preview_patch_bytes(&bytes, target)?; + if preview.token != token { + return Err(Error::Other( + "patch or repository changed; preview again".into(), + )); + } + if !preview.valid && target != PatchTarget::Mailbox { + return Err(Error::Other(preview.validation)); + } + let scratch = InterchangeScratch::new()?; + let input = scratch.0.join("input"); + fs::write(&input, bytes)?; + let mut args = match target { + PatchTarget::Mailbox => { + let out = checked(git( + &self.path, + &["status", "--porcelain", "--untracked-files=normal"], + None, + None, + )?)?; + if !out.stdout.is_empty() { + return Err(Error::Other("mailbox import requires a clean index and working tree; commit or stash changes first".into())); + } + vec!["am", "--3way"] + } + PatchTarget::Index => vec!["apply", "--cached"], + PatchTarget::Both => vec!["apply", "--index"], + PatchTarget::Worktree => vec!["apply"], + }; + args.extend(["--", utf8_path(&input)?]); + let out = git(&self.path, &args, None, None)?; + Ok(InterchangeOutcome { + success: out.status.success(), + paused: self.operation_in_progress().as_deref() == Some("mailbox"), + output: diagnostic(&out), + }) + } + + pub fn mailbox_state(&self) -> Result> { + if self.operation_in_progress().as_deref() != Some("mailbox") { + return Ok(None); + } + let dir = self.git_dir().join("rebase-apply"); + let read = |n: &str| -> Result { + Ok( + String::from_utf8_lossy(&bounded_read(&dir.join(n), MAX_PATCH)?) + .trim() + .to_owned(), + ) + }; + let mut index = self.git2()?.index()?; + index.read(true)?; + Ok(Some(MailboxState { + token: self.import_stamp(&[], &[])?, + current: read("next")?, + total: read("last")?, + author: read("info").unwrap_or_default(), + conflicts: index.has_conflicts(), + })) + } + + pub fn mailbox_action(&self, action: MailboxAction, token: &str) -> Result { + let state = self + .mailbox_state()? + .ok_or_else(|| Error::Other("no mailbox operation in progress".into()))?; + if state.token != token { + return Err(Error::Other( + "mailbox state changed; refresh before continuing".into(), + )); + } + if matches!(action, MailboxAction::Continue) && state.conflicts { + return Err(Error::Other( + "resolve and stage every conflict first".into(), + )); + } + let arg = match action { + MailboxAction::Continue => "--continue", + MailboxAction::Skip => "--skip", + MailboxAction::Abort => "--abort", + }; + let out = git(&self.path, &["am", arg], None, None)?; + Ok(InterchangeOutcome { + success: out.status.success(), + paused: self.operation_in_progress().as_deref() == Some("mailbox"), + output: diagnostic(&out), + }) + } + + pub fn preview_bundle(&self, source: &Path) -> Result { + if !source.is_absolute() { + return Err(Error::Other( + "bundle source must be an absolute path".into(), + )); + } + // Header is bounded even though pack data can be arbitrarily large. + let mut header = Vec::new(); + use std::io::BufRead; + let mut input = std::io::BufReader::new(fs::File::open(source)?); + loop { + let mut line = Vec::new(); + input + .by_ref() + .take(1024 * 1024 + 1) + .read_until(b'\n', &mut line)?; + if line.is_empty() { + return Err(Error::Other("incomplete bundle header".into())); + } + if header.len() + line.len() > 1024 * 1024 { + return Err(Error::Other("bundle header exceeds 1 MiB".into())); + } + header.extend_from_slice(&line); + if line == b"\n" { + break; + } + } + let text = String::from_utf8_lossy(&header); + let prerequisites = text + .lines() + .filter_map(|l| l.strip_prefix('-').map(str::to_owned)) + .collect(); + let heads = checked(git( + &self.path, + &["bundle", "list-heads", utf8_path(source)?], + None, + None, + )?)?; + let refs = String::from_utf8_lossy(&heads.stdout) + .lines() + .filter_map(|l| { + l.split_once(' ').map(|(oid, name)| BundleRef { + oid: oid.into(), + name: name.into(), + }) + }) + .collect(); + let out = git( + &self.path, + &["bundle", "verify", utf8_path(source)?], + None, + None, + )?; + Ok(BundlePreview { + token: file_stamp(source)?, + refs, + prerequisites, + valid: out.status.success(), + validation: diagnostic(&out), + }) + } + + /// Fetch exactly one reviewed bundle ref into a new local branch. Existing + /// refs/HEAD are never overwritten and Git owns object/prerequisite validation. + pub fn import_bundle( + &self, + source: &Path, + token: &str, + source_ref: &str, + branch: &str, + ) -> Result { + let scratch = InterchangeScratch::new()?; + let copy = scratch.0.join("input.bundle"); + fs::copy(source, ©)?; + let preview = self.preview_bundle(©)?; + if preview.token != token { + return Err(Error::Other("bundle changed; verify again".into())); + } + if !preview.valid { + return Err(Error::Other(preview.validation)); + } + let selected = preview + .refs + .iter() + .find(|r| r.name == source_ref) + .ok_or_else(|| Error::Other("choose an advertised bundle ref".into()))?; + let dest = format!("refs/heads/{branch}"); + if branch.starts_with('-') || !git2::Reference::is_valid_name(&dest) { + return Err(Error::Other("invalid destination branch".into())); + } + // Import objects without publishing a ref, then use a compare-and-swap + // create. A concurrent external branch creation must never be overwritten. + checked(git( + &self.path, + &["bundle", "unbundle", utf8_path(©)?], + None, + None, + )?)?; + let commit = self + .git2()? + .find_object(git2::Oid::from_str(&selected.oid)?, None)? + .peel_to_commit()? + .id(); + self.git2()? + .reference(&dest, commit, false, "Strand bundle import")?; + Ok(InterchangeOutcome { + success: true, + paused: false, + output: format!("Imported {} at {} into {dest}", selected.name, selected.oid), + }) + } + + /// Export a named ref, optionally excluding a prerequisite revision. Verify + /// the exported tip before publishing, so concurrent ref updates fail closed. + pub fn export_bundle( + &self, + destination: &Path, + refname: &str, + prerequisite: Option<&str>, + ) -> Result { + if !destination.is_absolute() || destination.exists() { + return Err(Error::Other( + "choose a new absolute bundle destination; existing files are never overwritten" + .into(), + )); + } + let parent = destination + .parent() + .ok_or_else(|| Error::Other("destination has no parent".into()))? + .canonicalize()?; + if parent.starts_with(self.git_dir().canonicalize()?) + || parent.starts_with(self.gix.common_dir().canonicalize()?) + { + return Err(Error::Other( + "bundle destination cannot be inside Git administrative directories".into(), + )); + } + let reference = self.git2()?.find_reference(refname)?; + let tip = reference.peel_to_commit()?.id().to_string(); + let base = prerequisite + .map(|r| { + self.git2()? + .revparse_single(r)? + .peel_to_commit() + .map(|c| format!("^{}", c.id())) + .map_err(Error::from) + }) + .transpose()?; + // git bundle requires a named positive ref. Verify it again after creation + // and fail without publishing the artifact if it moved during export. + let scratch = InterchangeScratch::new()?; + let output = scratch.0.join("export.bundle"); + let mut args = vec!["bundle", "create", utf8_path(&output)?, refname]; + if let Some(b) = base.as_deref() { + args.push(b); + } + checked(git(&self.path, &args, None, None)?)?; + let preview = self.preview_bundle(&output)?; + if !preview + .refs + .iter() + .any(|r| r.name == refname && r.oid == tip) + { + return Err(Error::Other("export ref changed; retry".into())); + } + let mut dest = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(destination)?; + if let Err(e) = + std::io::copy(&mut fs::File::open(output)?, &mut dest).and_then(|_| dest.flush()) + { + drop(dest); + let _ = fs::remove_file(destination); + return Err(e.into()); + } + Ok(preview) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> (InterchangeScratch, Repo) { + let scratch = InterchangeScratch::new().unwrap(); + let repo = git2::Repository::init(&scratch.0).unwrap(); + let mut config = repo.config().unwrap(); + config.set_str("user.name", "Committer").unwrap(); + config + .set_str("user.email", "committer@example.test") + .unwrap(); + config.set_bool("commit.gpgsign", false).unwrap(); + config.set_bool("core.autocrlf", false).unwrap(); + config.set_str("core.hooksPath", "/dev/null").unwrap(); + fs::write(scratch.0.join("file.txt"), "one\n").unwrap(); + checked(git(&scratch.0, &["add", "."], None, None).unwrap()).unwrap(); + checked(git(&scratch.0, &["commit", "-m", "base"], None, None).unwrap()).unwrap(); + (scratch, Repo::discover(repo.workdir().unwrap()).unwrap()) + } + fn patch() -> &'static str { + "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-one\n+two\n" + } + fn input(s: &InterchangeScratch, data: &str) -> PathBuf { + let p = s.0.join("input.patch"); + fs::write(&p, data).unwrap(); + p + } + + #[test] + fn patch_targets_validation_and_stale_preview() { + for target in [PatchTarget::Worktree, PatchTarget::Index, PatchTarget::Both] { + let (_s, repo) = fixture(); + let input_dir = InterchangeScratch::new().unwrap(); + let p = input(&input_dir, patch()); + let preview = repo.preview_patch_import(&p, target).unwrap(); + assert!(preview.valid, "{}", preview.validation); + assert_eq!(preview.paths, ["file.txt"]); + assert!( + repo.import_patch(&p, target, &preview.token) + .unwrap() + .success + ); + let wt = fs::read_to_string(repo.path.join("file.txt")).unwrap(); + assert_eq!( + wt, + if target == PatchTarget::Index { + "one\n" + } else { + "two\n" + } + ); + let staged = + checked(git(&repo.path, &["show", ":file.txt"], None, None).unwrap()).unwrap(); + assert_eq!( + staged.stdout, + if target == PatchTarget::Worktree { + b"one\n" + } else { + b"two\n" + } + ); + assert!(repo.import_patch(&p, target, &preview.token).is_err()); + } + let (_s, repo) = fixture(); + let s = InterchangeScratch::new().unwrap(); + let p = input(&s, patch()); + let preview = repo + .preview_patch_import(&p, PatchTarget::Worktree) + .unwrap(); + fs::write(repo.path.join("file.txt"), "external edit\n").unwrap(); + assert!(repo + .import_patch(&p, PatchTarget::Worktree, &preview.token) + .is_err()); + assert!( + !repo + .preview_patch_import(&p, PatchTarget::Worktree) + .unwrap() + .valid + ); + } + + #[test] + fn reject_path_traversal_admin_and_nested_symlinks() { + let (_s, repo) = fixture(); + for path in [ + "../outside", + "/absolute", + ".git/config", + "C:/outside", + "dir/../../out", + "dir\\..\\out", + ] { + assert!(repo.check_import_path(path).is_err(), "{path}"); + } + assert!(repo.check_import_path("new/nested/file").is_ok()); + let s = InterchangeScratch::new().unwrap(); + let p = input(&s, &patch().replace("file.txt", "../outside")); + assert!(repo + .preview_patch_import(&p, PatchTarget::Worktree) + .is_err()); + #[cfg(unix)] + { + std::os::unix::fs::symlink(&s.0, repo.path.join("link")).unwrap(); + assert!(repo.check_import_path("link/new/file").is_err()); + } + } + + fn mailbox(repo: &Repo, dir: &InterchangeScratch) -> PathBuf { + fs::write(repo.path.join("file.txt"), "two\n").unwrap(); + checked( + git( + &repo.path, + &[ + "commit", + "-am", + "authored change", + "--author=Original Author ", + ], + None, + None, + ) + .unwrap(), + ) + .unwrap(); + let output = checked( + git( + &repo.path, + &["format-patch", "--stdout", "-1", "HEAD"], + None, + None, + ) + .unwrap(), + ) + .unwrap(); + checked(git(&repo.path, &["reset", "--hard", "HEAD~1"], None, None).unwrap()).unwrap(); + let p = dir.0.join("mailbox"); + fs::write(&p, output.stdout).unwrap(); + p + } + + #[test] + fn mailbox_preserves_authors_and_recovers_continue_skip_abort() { + for action in [ + MailboxAction::Continue, + MailboxAction::Skip, + MailboxAction::Abort, + ] { + let (_s, repo) = fixture(); + let dir = InterchangeScratch::new().unwrap(); + let p = mailbox(&repo, &dir); + fs::write(repo.path.join("file.txt"), "diverged\n").unwrap(); + checked(git(&repo.path, &["commit", "-am", "divergence"], None, None).unwrap()) + .unwrap(); + let original = repo.git2().unwrap().head().unwrap().target().unwrap(); + let preview = repo.preview_patch_import(&p, PatchTarget::Mailbox).unwrap(); + assert!(preview.messages[0].contains("Original Author")); + let outcome = repo + .import_patch(&p, PatchTarget::Mailbox, &preview.token) + .unwrap(); + assert!(outcome.paused, "{}", outcome.output); + assert_eq!( + Repo::discover(&repo.path) + .unwrap() + .meta() + .unwrap() + .operation + .as_deref(), + Some("mailbox") + ); + let before = repo.mailbox_state().unwrap().unwrap(); + assert!(before.author.contains("Original Author")); + if matches!(action, MailboxAction::Continue) { + fs::write(repo.path.join("file.txt"), "resolved\n").unwrap(); + checked(git(&repo.path, &["add", "file.txt"], None, None).unwrap()).unwrap(); + assert!(repo.mailbox_action(action, &before.token).is_err()); + } + let state = repo.mailbox_state().unwrap().unwrap(); + let outcome = repo.mailbox_action(action, &state.token).unwrap(); + assert!(outcome.success && !outcome.paused, "{}", outcome.output); + let fresh = git2::Repository::open(&repo.path).unwrap(); + let head = fresh.head().unwrap().peel_to_commit().unwrap(); + if matches!(action, MailboxAction::Continue) { + assert_eq!(head.author().email(), Some("author@example.test")); + } else { + assert_eq!(head.id(), original); + } + } + } + + #[test] + fn clean_mailbox_series_previews_every_author_and_applies_in_order() { + let (_s, repo) = fixture(); + let dir = InterchangeScratch::new().unwrap(); + for (text, author) in [ + ("two\n", "First "), + ("three\n", "Second "), + ] { + fs::write(repo.path.join("file.txt"), text).unwrap(); + checked( + git( + &repo.path, + &["commit", "-am", "series", &format!("--author={author}")], + None, + None, + ) + .unwrap(), + ) + .unwrap(); + } + let out = checked( + git( + &repo.path, + &["format-patch", "--stdout", "-2", "HEAD"], + None, + None, + ) + .unwrap(), + ) + .unwrap(); + checked(git(&repo.path, &["reset", "--hard", "HEAD~2"], None, None).unwrap()).unwrap(); + let p = dir.0.join("series"); + fs::write(&p, out.stdout).unwrap(); + let preview = repo.preview_patch_import(&p, PatchTarget::Mailbox).unwrap(); + assert!(preview.valid, "{}", preview.validation); + assert_eq!(preview.messages.len(), 2); + assert!(preview.messages[0].contains("first@example.test")); + assert!(preview.messages[1].contains("second@example.test")); + assert!( + repo.import_patch(&p, PatchTarget::Mailbox, &preview.token) + .unwrap() + .success + ); + assert_eq!( + fs::read_to_string(repo.path.join("file.txt")).unwrap(), + "three\n" + ); + assert!(repo.mailbox_state().unwrap().is_none()); + } + + #[test] + fn bundles_verify_prerequisites_and_never_overwrite_refs() { + let (_s, repo) = fixture(); + let dir = InterchangeScratch::new().unwrap(); + let full = dir.0.join("full.bundle"); + let refname = repo + .git2() + .unwrap() + .head() + .unwrap() + .name() + .unwrap() + .to_owned(); + let full_preview = repo.export_bundle(&full, &refname, None).unwrap(); + assert!(full_preview.valid && full_preview.prerequisites.is_empty()); + let (_r, receiver) = fixture(); + let outcome = receiver + .import_bundle(&full, &full_preview.token, &refname, "imported") + .unwrap(); + assert!(outcome.success); + assert!(receiver + .import_bundle(&full, &full_preview.token, &refname, "imported") + .is_err()); + assert!(repo.export_bundle(&full, &refname, None).is_err()); + fs::write(repo.path.join("file.txt"), "next\n").unwrap(); + checked(git(&repo.path, &["commit", "-am", "next"], None, None).unwrap()).unwrap(); + let incremental = dir.0.join("incremental.bundle"); + let preview = repo + .export_bundle(&incremental, &refname, Some("HEAD~1")) + .unwrap(); + assert_eq!(preview.prerequisites.len(), 1); + let empty = InterchangeScratch::new().unwrap(); + git2::Repository::init(&empty.0).unwrap(); + let empty_repo = Repo::discover(&empty.0).unwrap(); + assert!(!empty_repo.preview_bundle(&incremental).unwrap().valid); + assert!(empty_repo + .import_bundle(&incremental, &preview.token, &refname, "missing") + .is_err()); + } +} diff --git a/crates/strand-core/src/lib.rs b/crates/strand-core/src/lib.rs index 49d9174..caa4b61 100644 --- a/crates/strand-core/src/lib.rs +++ b/crates/strand-core/src/lib.rs @@ -21,6 +21,9 @@ pub mod log; pub mod diff; pub mod stage; pub mod apply; +pub mod interchange; +pub mod advanced_refs; +pub mod bisect; pub mod commit; pub mod signing; pub mod commit_metadata; @@ -34,6 +37,8 @@ pub mod conflict; pub mod external; pub mod user_actions; pub mod gitconfig; +pub mod gitflow; + mod git_output; pub mod history; pub mod ignore; diff --git a/crates/strand-core/src/repo.rs b/crates/strand-core/src/repo.rs index a7e1fc0..9e0ea39 100644 --- a/crates/strand-core/src/repo.rs +++ b/crates/strand-core/src/repo.rs @@ -111,14 +111,16 @@ impl Repo { /// Which multi-step history op (if any) is paused mid-flight, detected from /// the on-disk markers git leaves in `.git/`. Returns one of `"rebase"`, - /// `"cherry-pick"`, `"revert"`, `"merge"`, or `None`. Order matters: a + /// `"cherry-pick"`, `"revert"`, `"merge"`, `"mailbox"`, or `None`. Order matters: a /// rebase can leave a `MERGE_HEAD` while resolving, so rebase is checked /// first. Used by [`meta`](Repo::meta) (UI banner) and /// [`abort_operation`](crate::repo::Repo::abort_operation). pub(crate) fn operation_in_progress(&self) -> Option { let git_dir = self.gix.git_dir(); let has = |name: &str| git_dir.join(name).exists(); - if has("rebase-merge") || has("rebase-apply") { + if has("rebase-apply/applying") { + Some("mailbox".into()) + } else if has("rebase-merge") || has("rebase-apply") { Some("rebase".into()) } else if has("CHERRY_PICK_HEAD") { Some("cherry-pick".into()) @@ -126,6 +128,8 @@ impl Repo { Some("revert".into()) } else if has("MERGE_HEAD") { Some("merge".into()) + } else if has("BISECT_START") { + Some("bisect".into()) } else { None } diff --git a/crates/strand-core/src/watch.rs b/crates/strand-core/src/watch.rs index 97a09a3..ccbceed 100644 --- a/crates/strand-core/src/watch.rs +++ b/crates/strand-core/src/watch.rs @@ -149,6 +149,12 @@ fn relevant_path(path: &Path, git_dir: &Path) -> bool { | "rebase-apply" | "info" | "config" + | "BISECT_START" + | "BISECT_LOG" + | "BISECT_TERMS" + | "BISECT_HEAD" + | "BISECT_EXPECTED_REV" + | "config.worktree" ) } @@ -178,6 +184,10 @@ mod tests { "/repo/.git/refs/heads/main", "/repo/.git/MERGE_HEAD", "/repo/.git/rebase-merge/done", + "/repo/.git/rebase-apply/applying", + "/repo/.git/rebase-apply/next", + "/repo/.git/BISECT_START", + "/repo/.git/BISECT_LOG", ] { assert!(relevant_path(&PathBuf::from(p), &git_dir()), "{p} should refresh"); } diff --git a/crates/strand-tauri/src/commands.rs b/crates/strand-tauri/src/commands.rs index 87478e1..136280d 100644 --- a/crates/strand-tauri/src/commands.rs +++ b/crates/strand-tauri/src/commands.rs @@ -72,6 +72,187 @@ impl From for CmdError { pub(crate) type CmdResult = std::result::Result; +#[tauri::command] +pub async fn repo_gitflow_detect() -> CmdResult { + run_blocking("detect Git-flow", || strand_core::gitflow::detect().map_err(Into::into)).await +} +#[tauri::command] +pub async fn repo_gitflow_state(path: String) -> CmdResult { + run_blocking("inspect Git-flow", move || Repo::discover(path)?.gitflow_state().map_err(Into::into)).await +} +#[tauri::command] +pub async fn repo_gitflow_configure(path: String, config: strand_core::gitflow::FlowConfig, enabled: bool, token: String) -> CmdResult { + run_blocking("configure Git-flow", move || Repo::discover(path)?.configure_gitflow(config, enabled, &token).map_err(Into::into)).await +} +#[tauri::command] +pub async fn repo_gitflow_plan(path: String, kind: strand_core::gitflow::FlowKind, action: strand_core::gitflow::FlowAction, name: String) -> CmdResult { + run_blocking("review Git-flow", move || Repo::discover(path)?.plan_gitflow(kind, action, &name).map_err(Into::into)).await +} +#[tauri::command] +pub async fn repo_gitflow_run(path: String, plan: strand_core::gitflow::FlowPlan, on_event: Channel) -> CmdResult { + run_blocking("run Git-flow", move || Repo::discover(path)?.run_gitflow(plan, |text| { let _ = on_event.send(text); }).map_err(Into::into)).await +} + +#[tauri::command] +pub async fn repo_advanced_refs( + path: String, + notes_ref: String, +) -> CmdResult { + run_blocking("inspect advanced refs", move || { + Repo::discover(path)? + .advanced_refs(¬es_ref) + .map_err(Into::into) + }) + .await +} +#[tauri::command] +pub async fn repo_git_note( + path: String, + notes_ref: String, + revision: String, +) -> CmdResult { + run_blocking("read Git note", move || { + Repo::discover(path)? + .git_note(¬es_ref, &revision) + .map_err(Into::into) + }) + .await +} +#[tauri::command] +pub async fn repo_git_note_write( + path: String, + notes_ref: String, + object: String, + expected: Option, + message: Option, +) -> CmdResult<()> { + run_blocking("write Git note", move || { + Repo::discover(path)? + .write_git_note(¬es_ref, &object, expected.as_deref(), message.as_deref()) + .map_err(Into::into) + }) + .await +} +#[tauri::command] +pub async fn repo_replace_review( + path: String, + original: String, + replacement: String, +) -> CmdResult { + run_blocking("review replacement", move || { + Repo::discover(path)? + .review_replacement(&original, &replacement) + .map_err(Into::into) + }) + .await +} +#[tauri::command] +pub async fn repo_replace_write( + path: String, + original: String, + replacement: Option, + expected: Option, +) -> CmdResult<()> { + run_blocking("write replacement", move || { + Repo::discover(path)? + .write_replacement(&original, replacement.as_deref(), expected.as_deref()) + .map_err(Into::into) + }) + .await +} +#[tauri::command] +pub async fn repo_tag_edit_review( + path: String, + name: String, + target: String, +) -> CmdResult { + run_blocking("review tag edit", move || { + Repo::discover(path)? + .review_tag_edit(&name, &target) + .map_err(Into::into) + }) + .await +} +#[tauri::command] +pub async fn repo_tag_edit( + path: String, + name: String, + target: String, + expected: String, + kind: strand_core::advanced_refs::TagEditKind, + message: Option, +) -> CmdResult<()> { + run_blocking("edit tag", move || { + Repo::discover(path)? + .edit_tag(&name, &target, &expected, kind, message.as_deref()) + .map_err(Into::into) + }) + .await +} +#[tauri::command] +pub async fn repo_tag_published( + path: String, + remote: String, + name: String, +) -> CmdResult { + run_blocking("check published tag", move || { + Repo::discover(path)? + .published_tag(&remote, &name) + .map_err(Into::into) + }) + .await +} + +#[tauri::command] +pub async fn repo_bisect_state(path: String) -> CmdResult { + run_blocking("bisect state", move || Repo::discover(path)?.bisect_state().map_err(Into::into)).await +} + +#[tauri::command] +pub async fn repo_bisect_start(path: String, good: String, bad: String, token: String) -> CmdResult { + run_blocking("start bisect", move || Repo::discover(path)?.bisect_start(&good, &bad, &token).map_err(Into::into)).await +} + +#[tauri::command] +pub async fn repo_bisect_action(path: String, action: strand_core::bisect::BisectAction, token: String) -> CmdResult { + run_blocking("bisect action", move || Repo::discover(path)?.bisect_action(action, &token).map_err(Into::into)).await +} + +#[tauri::command] +pub async fn repo_patch_preview(path: String, source: String, target: strand_core::interchange::PatchTarget) -> CmdResult { + run_blocking("preview patch", move || Repo::discover(path)?.preview_patch_import(Path::new(&source), target).map_err(Into::into)).await +} + +#[tauri::command] +pub async fn repo_patch_import(path: String, source: String, target: strand_core::interchange::PatchTarget, token: String) -> CmdResult { + run_blocking("import patch", move || Repo::discover(path)?.import_patch(Path::new(&source), target, &token).map_err(Into::into)).await +} + +#[tauri::command] +pub async fn repo_mailbox_state(path: String) -> CmdResult> { + run_blocking("mailbox state", move || Repo::discover(path)?.mailbox_state().map_err(Into::into)).await +} + +#[tauri::command] +pub async fn repo_mailbox_action(path: String, action: strand_core::interchange::MailboxAction, token: String) -> CmdResult { + run_blocking("mailbox action", move || Repo::discover(path)?.mailbox_action(action, &token).map_err(Into::into)).await +} + +#[tauri::command] +pub async fn repo_bundle_preview(path: String, source: String) -> CmdResult { + run_blocking("verify bundle", move || Repo::discover(path)?.preview_bundle(Path::new(&source)).map_err(Into::into)).await +} + +#[tauri::command] +pub async fn repo_bundle_import(path: String, source: String, token: String, source_ref: String, branch: String) -> CmdResult { + run_blocking("import bundle", move || Repo::discover(path)?.import_bundle(Path::new(&source), &token, &source_ref, &branch).map_err(Into::into)).await +} + +#[tauri::command] +pub async fn repo_bundle_export(path: String, destination: String, refname: String, prerequisite: Option) -> CmdResult { + run_blocking("export bundle", move || Repo::discover(path)?.export_bundle(Path::new(&destination), &refname, prerequisite.as_deref()).map_err(Into::into)).await +} + #[tauri::command(async)] pub fn repo_terminal_create( path: String, diff --git a/crates/strand-tauri/src/main.rs b/crates/strand-tauri/src/main.rs index 301d102..a8356a8 100644 --- a/crates/strand-tauri/src/main.rs +++ b/crates/strand-tauri/src/main.rs @@ -282,6 +282,30 @@ fn main() { commands::repo_remote_set_urls, commands::repo_remote_set_default, commands::repo_maintenance, + commands::repo_gitflow_detect, + commands::repo_gitflow_state, + commands::repo_gitflow_configure, + commands::repo_gitflow_plan, + commands::repo_gitflow_run, + commands::repo_advanced_refs, + commands::repo_git_note, + commands::repo_git_note_write, + commands::repo_replace_review, + commands::repo_replace_write, + commands::repo_tag_edit_review, + commands::repo_tag_edit, + commands::repo_tag_published, + commands::repo_bisect_state, + commands::repo_bisect_start, + commands::repo_bisect_action, + commands::repo_patch_preview, + commands::repo_patch_import, + commands::repo_mailbox_state, + commands::repo_mailbox_action, + commands::repo_bundle_preview, + commands::repo_bundle_import, + commands::repo_bundle_export, + commands::repo_user_action_preview, commands::repo_user_action_run, diff --git a/docs/learnings.md b/docs/learnings.md index efc6aa1..64ee69d 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -1,5 +1,30 @@ # Learnings +## Bisect ratings belong to the expected revision (2026-09-06) + +Read `BISECT_*` and refs from Git for every dialog refresh/action; these are +worktree-local and may be driven by another client. Map custom terms and +`BISECT_HEAD` for no-checkout sessions, distinguish skipped ambiguity from a +culprit, and reject a rating when HEAD differs from `BISECT_EXPECTED_REV`. +Require a clean tree/index before checkout transitions and reset; test edits +must not be discarded. Review the original ref's current target again before +reset. A bisect marker remaining after a successful merge/rebase does not mean +that sequencer is still paused. Dialogs that remain open after a busy action +must restore focus once controls are enabled again; disabling the focused +button can move focus out of the modal even with a correct Tab trap. + +## Interchange state comes from Git, not a saved UI session (2026-09-06) + +`rebase-apply/applying` identifies `git am`; `rebase-apply` alone can mean a +rebase. Test this before the generic rebase check so Continue/Abort dispatch +to the right porcelain. Mailbox previews parse every message with Git's +mailsplit/mailinfo, preserving authors and checking old/new paths. Imported +paths reject administrative entries and symlink traversal, including missing +descendants; never enable `--unsafe-paths`. Preview stamps include file bytes, +index and HEAD because status-row equality does not prove unchanged content. +Bundle imports publish only a new local branch after verification/unbundle, +using non-forcing ref creation to reject concurrent external branch creation. + ## Sparse indexes and promised blobs require Git-aware paths (2026-09-06) libgit2 1.8 cannot read the mandatory sparse-directory index extension and @@ -2556,6 +2581,29 @@ Pierre reads `navigator.userAgent` during module evaluation; Node 22's built-in after the test, while retaining real integration assertions. Reproduce this class of failure locally with `--no-experimental-global-navigator`. + +### Advanced refs preserve reviewed identities (2026-09-06) + +Git notes must use a locked namespace tip and publish the prepared notes tree +atomically; a stale note editor must retain its draft when another worktree +changes that namespace. Replacement inspection uses raw object IDs because +libgit2/gix readers do not apply Git's replace refs. Tag retargeting and +re-annotation are separate operations with compare-and-swap of the raw tag ref, +not its peeled commit. Never drop an existing tag signature during an edit. + + +### Git-flow finish is a resumable sequence, not a transaction (2026-09-06) + +Git-flow AVH can finish its production merge and tag before a develop merge +conflicts. Abort must be described as aborting only that current merge; it must +never reset earlier completed stages. Retain workflow branches and use exact +names so a later finish can resume. AVH flags can default from Git config: +explicitly negate every publication flag (including release pushproduction, +pushdevelop and pushtag), not just push. AVH builds its tag command with shell +`eval`; use reviewed generated annotation text from validated names rather +than interpolating arbitrary editor text. Keep tool detection and all of this +metadata off repository-open and graph/diff hot paths. + ## Personal actions preserve argv and captured targets (2026-09-06) User actions are personal executable/argv definitions, separate from Workbench diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 98c9114..86fe54e 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -128,6 +128,11 @@ const SettingsDialog = lazy(() => import('./views/SettingsDialog').then((m) => ( const BranchCleanupDialog = lazy(() => import('./views/BranchCleanupDialog').then((m) => ({ default: m.BranchCleanupDialog }))); const RebaseEditor = lazy(() => import('./views/RebaseEditor').then((m) => ({ default: m.RebaseEditor }))); const MaintenanceDialog = lazy(() => import('./views/MaintenanceDialog').then((m) => ({ default: m.MaintenanceDialog }))); +const InterchangeDialog = lazy(() => import('./views/InterchangeDialog').then((m) => ({ default: m.InterchangeDialog }))); +const GitflowDialog = lazy(() => import('./views/GitflowDialog').then((m) => ({ default: m.GitflowDialog }))); +const AdvancedRefsDialog = lazy(() => import('./views/AdvancedRefsDialog').then((m) => ({ default: m.AdvancedRefsDialog }))); +const BisectDialog = lazy(() => import('./views/BisectDialog').then((m) => ({ default: m.BisectDialog }))); + const UserActionDialog = lazy(() => import('./views/UserActionDialog').then((m) => ({ default: m.UserActionDialog }))); const LfsDialog = lazy(() => import('./views/LfsDialog').then((m) => ({ default: m.LfsDialog }))); @@ -371,6 +376,11 @@ export function App() { // null = closed; otherwise which remote-management flavour (add/rename/url). const [remoteDialog, setRemoteDialog] = useState(null); const [maintenanceOpen, setMaintenanceOpen] = useState(false); + const [interchangePath, setInterchangePath] = useState(null); + const [gitflowPath, setGitflowPath] = useState(null); + const [advancedRefs, setAdvancedRefs] = useState<{ path: string; mode: 'notes' | 'replace' | 'retarget' | 'reannotate'; tag?: string } | null>(null); + const [bisectPath, setBisectPath] = useState(null); + const userActions = useSettings((state) => state.userActions); const [userActionRequest, setUserActionRequest] = useState<(ActionRequest & { key: string }) | null>(null); useEffect(() => { @@ -1215,6 +1225,10 @@ export function App() { push: () => { void onPush(); }, openInEditor, openInTerminal, + openInterchange: () => { const path = useRepo.getState().activePath; if (path) setInterchangePath(path); }, + openGitflow: () => { const path = useRepo.getState().activePath; if (path) setGitflowPath(path); }, + openAdvancedRefs: () => { const path = useRepo.getState().activePath; if (path) setAdvancedRefs({ path, mode: 'notes' }); }, + openBisect: () => { const path = useRepo.getState().activePath; if (path) setBisectPath(path); }, }; const hasRepo = Boolean(meta); useEffect(() => { @@ -1930,6 +1944,14 @@ export function App() { ] : []), { id: 'remote-add', label: 'Add remote…', group: 'Actions', keywords: 'remote origin upstream url add', run: () => setRemoteDialog({ kind: 'add' }) }, + { id: 'git-interchange', label: 'Patches, mailboxes & bundles…', group: 'Actions', keywords: 'import export apply index working tree am continue skip abort author bundle verify prerequisites', run: () => { setPaletteOpen(false); setInterchangePath(meta.path); } }, + { id: 'git-notes', label: 'Git notes…', group: 'Actions', keywords: 'advanced refs objects notes replacements tag annotation', run: () => { setPaletteOpen(false); setAdvancedRefs({ path: meta.path, mode: 'notes' }); } }, + { id: 'git-replace', label: 'Replace refs…', group: 'Actions', keywords: 'advanced refs objects notes replacements tag annotation', run: () => { setPaletteOpen(false); setAdvancedRefs({ path: meta.path, mode: 'replace' }); } }, + { id: 'git-retarget', label: 'Retarget tag…', group: 'Actions', keywords: 'advanced refs objects notes replacements tag annotation', run: () => { setPaletteOpen(false); setAdvancedRefs({ path: meta.path, mode: 'retarget' }); } }, + { id: 'git-reannotate', label: 'Re-annotate tag…', group: 'Actions', keywords: 'advanced refs objects notes replacements tag annotation', run: () => { setPaletteOpen(false); setAdvancedRefs({ path: meta.path, mode: 'reannotate' }); } }, + { id: 'gitflow', label: 'Git-flow workflows…', group: 'Actions', keywords: 'AVH feature release hotfix start finish resume configuration', run: () => { setPaletteOpen(false); setGitflowPath(meta.path); } }, + { id: 'git-bisect', label: 'Guided bisect…', group: 'Actions', keywords: 'good bad skip regression culprit test resume reset', run: () => { setPaletteOpen(false); setBisectPath(meta.path); } }, + { id: 'clone-scope', label: 'Repository history and downloads…', group: 'Actions', keywords: 'clone shallow partial filter deepen unshallow single branch', run: () => setCloneScopePath(meta.path) }, { id: 'sparse-checkout', label: 'Sparse checkout…', group: 'Actions', keywords: 'cone directories select inspect change disable excluded sparse index', run: () => setSparsePath(meta.path) }, @@ -2034,7 +2056,7 @@ export function App() { { id: 'toggle-sidebar', label: sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar', group: 'Actions', shortcut: keyHint('toggle-sidebar'), keywords: 'sidebar collapse expand hide show panel', run: toggleSidebar }, ); // Surface "Abort" in the palette only while an op is actually paused. - if (meta?.operation) { + if (meta?.operation && meta.operation !== 'bisect' && meta.operation !== 'mailbox') { base.push({ id: 'abort-op', label: `Abort ${meta.operation}`, @@ -2278,6 +2300,7 @@ export function App() { onCreateStash={() => setStashDialog({ snapshot: true, keepIndex: false })} onVerifyTag={(name) => { if (activePath) setTagVerification({ path: activePath, name }); }} onCreateTag={() => setTagDialog({ target: null, label: 'HEAD' })} + onEditTag={(tag, mode) => { if (meta) setAdvancedRefs({ path: meta.path, mode, tag }); }} onCreateBranch={(start, label) => setBranchDialog({ start, label })} onBranchFromStash={(index) => setBranchDialog({ start: `stash@{${index}}`, @@ -2320,7 +2343,7 @@ export function App() { ) : view === 'work' ? ( workbenchComposed ? (
- + { if (meta) setInterchangePath(meta.path); }} onOpenBisect={() => { if (meta) setBisectPath(meta.path); }} /> )} - + { if (meta) setInterchangePath(meta.path); }} onOpenBisect={() => { if (meta) setBisectPath(meta.path); }} /> {mainSurfaceId && ( setMaintenanceOpen(false)} onToast={showToast} /> )} + {interchangePath && setInterchangePath(null)} />} + {gitflowPath && setGitflowPath(null)} />} + {advancedRefs && setAdvancedRefs(null)} />} + {bisectPath && setBisectPath(null)} />} + {userActionRequest && ( setUserActionRequest(null)} @@ -2728,6 +2756,8 @@ function CrashToast({ /** Human label for an in-progress sequencer op (from `meta.operation`). */ const OP_LABEL: Record, string> = { + mailbox: 'Mailbox in progress', + bisect: 'Bisect in progress', rebase: 'Rebase in progress', 'cherry-pick': 'Cherry-pick in progress', revert: 'Revert in progress', @@ -2742,7 +2772,7 @@ const OP_LABEL: Record, string> = { * conflict remains. The op clears `operation` on the next refresh, which hides * the banner. */ -function OpBanner({ onToast }: { onToast: (msg: string, kind?: 'success' | 'error') => void }) { +function OpBanner({ onToast, onOpenMailbox, onOpenBisect }: { onToast: (msg: string, kind?: 'success' | 'error') => void; onOpenMailbox: () => void; onOpenBisect: () => void }) { const operation = useRepo((s) => s.meta?.operation ?? null); const status = useRepo((s) => s.status); const abortOperation = useRepo((s) => s.abortOperation); @@ -2752,6 +2782,8 @@ function OpBanner({ onToast }: { onToast: (msg: string, kind?: 'success' | 'erro const hasConflicts = useMemo(() => status.some((s) => s.kind === 'CONFLICTED'), [status]); if (!operation) return null; + if (operation === 'bisect') return
Bisect in progressTest the selected revision, then rate it.
; + if (operation === 'mailbox') return
Mailbox in progressResolve and stage conflicts, then continue the mailbox.
; const onAbort = async () => { if (busy) return; diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 315ad28..4f1183e 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -90,6 +90,8 @@ interface SidebarProps { onCreateStash: () => void; /** Open the New-tag dialog targeting HEAD. */ onCreateTag: () => void; + onEditTag: (name: string, kind: 'retarget' | 'reannotate') => void; + onVerifyTag: (name: string) => void; /** Open the New-branch dialog from `start` (`null` ⇒ HEAD); `label` is the * human name shown in the blurb. */ @@ -180,7 +182,7 @@ function sortTree(node: TreeNode, leafCmp: (a: T, b: T) => number): void { // ─── component ────────────────────────────────────────────────────────── -export function Sidebar({ onManageSubmodules, onManageLfs, onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, onVerifyTag, onCreateBranch, onBranchFromStash, onCreateWorktree, onMerge, onInteractiveRebase, onManageRemote, onRenameBranch, onManageBranchNetwork, onPull, onPush, onForcePush, onFetchBranch, onPullBranch, onOpenFileInEditor, onCreateFileEntry, onToast }: SidebarProps) { +export function Sidebar({ onManageSubmodules, onManageLfs, onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, onEditTag, onVerifyTag, onCreateBranch, onBranchFromStash, onCreateWorktree, onMerge, onInteractiveRebase, onManageRemote, onRenameBranch, onManageBranchNetwork, onPull, onPush, onForcePush, onFetchBranch, onPullBranch, onOpenFileInEditor, onCreateFileEntry, onToast }: SidebarProps) { const view = useRepo((s) => s.view); const setView = useRepo((s) => s.setView); const selectFile = useRepo((s) => s.selectFile); @@ -751,6 +753,9 @@ export function Sidebar({ onManageSubmodules, onManageLfs, onOpenWorkbench, onOp const tagMenu = (tg: Tag): MenuItem[] => { const items: MenuItem[] = [ + { label: 'Retarget tag…', onSelect: () => onEditTag(tg.name, 'retarget') }, + { label: 'Re-annotate tag…', onSelect: () => onEditTag(tg.name, 'reannotate') }, + userActionMenu({ path: meta!.path, target: { kind: 'ref', reference: tg.full_name, oid: tg.target } }), { label: 'Checkout', icon: 'branch', onSelect: () => void runBranchOp(() => checkoutCommit(tg.target)) }, { label: 'New branch from here…', icon: 'plus', onSelect: () => onCreateBranch(tg.full_name, tg.name) }, diff --git a/ui/src/lib/advancedRefs.ts b/ui/src/lib/advancedRefs.ts new file mode 100644 index 0000000..0304f8e --- /dev/null +++ b/ui/src/lib/advancedRefs.ts @@ -0,0 +1,7 @@ +export interface ObjectSummary { oid: string; kind: string; subject: string } +export interface AdvancedRefs { notes_refs: string[]; notes_tip: string | null; notes: Array<{ object: string; note: string }>; notes_truncated: boolean; replacements: Array<{ original: string; replacement: string }>; replacements_truncated: boolean } +export interface GitNote { target: ObjectSummary; ref_tip: string | null; message: string | null } +export interface ReplaceReview { original: ObjectSummary; replacement: ObjectSummary; previous: string | null } +export type TagEditKind = 'retarget' | 'reannotate'; +export interface TagEditReview { name: string; ref_oid: string; current: ObjectSummary; proposed: ObjectSummary; annotation: string | null; signed: boolean; changed_files: number; remotes: string[] } +export interface PublishedTag { remote: string; oid: string | null } diff --git a/ui/src/lib/bisect.test.ts b/ui/src/lib/bisect.test.ts new file mode 100644 index 0000000..ad761e5 --- /dev/null +++ b/ui/src/lib/bisect.test.ts @@ -0,0 +1,12 @@ +import { expect, it } from 'vitest'; +import { bisectRatingBlock, type BisectState } from './bisect'; +const state: BisectState = { active: true, token: 'a', original: 'main', original_tip: 'tip', current: 'candidate', subject: 'Change', expected: 'candidate', good_term: 'good', bad_term: 'bad', remaining: 8, remaining_truncated: false, range_error: '', culprit: null, ambiguous: false, no_checkout: false, clean: true, log: '' }; +it('allows the selected clean revision but blocks an external checkout', () => { + expect(bisectRatingBlock(state)).toBeNull(); + expect(bisectRatingBlock({ ...state, current: 'different' })).toContain('differs'); +}); +it('preserves test edits and blocks rating a completed or ambiguous result', () => { + expect(bisectRatingBlock({ ...state, clean: false })).toContain('stash'); + expect(bisectRatingBlock({ ...state, culprit: 'found' })).toContain('reset'); + expect(bisectRatingBlock({ ...state, ambiguous: true })).toContain('reset'); +}); diff --git a/ui/src/lib/bisect.ts b/ui/src/lib/bisect.ts new file mode 100644 index 0000000..4da76f0 --- /dev/null +++ b/ui/src/lib/bisect.ts @@ -0,0 +1,16 @@ +export type BisectAction = 'good' | 'bad' | 'skip' | 'reset'; +export interface BisectState { + active: boolean; token: string; original: string; original_tip: string; current: string; subject: string; + expected: string; good_term: string; bad_term: string; remaining: number; + remaining_truncated: boolean; range_error: string; culprit: string | null; ambiguous: boolean; + no_checkout: boolean; clean: boolean; log: string; +} +export interface BisectOutcome { success: boolean; output: string; state: BisectState } + +export function bisectRatingBlock(state: BisectState): string | null { + if (!state.active) return 'Start a bisect session first.'; + if (!state.clean) return 'Commit or stash test edits before changing the checkout.'; + if (state.culprit || state.ambiguous) return 'Review the result, then reset to your original checkout.'; + if (state.expected && state.current !== state.expected) return 'The checkout differs from Git’s selected test revision. Restore the expected revision before rating.'; + return null; +} diff --git a/ui/src/lib/gitflow.ts b/ui/src/lib/gitflow.ts new file mode 100644 index 0000000..e0fafef --- /dev/null +++ b/ui/src/lib/gitflow.ts @@ -0,0 +1,7 @@ +export interface FlowConfig { production: string; develop: string; feature: string; release: string; hotfix: string; version_tag: string } +export interface FlowState { enabled: boolean; config: FlowConfig; options: Record; branches: Record; current: string; head: string; operation: string | null; clean: boolean; conflicts: boolean; token: string } +export type FlowKind = 'feature' | 'release' | 'hotfix'; +export type FlowAction = 'start' | 'finish' | 'continue_merge' | 'abort_merge'; +export interface FlowPlan { kind: FlowKind; action: FlowAction; name: string; token: string; args: string[]; steps: string[] } +export interface FlowTool { available: boolean; version: string } +export interface FlowOutcome { success: boolean; output: string; state: FlowState } diff --git a/ui/src/lib/interchange.test.ts b/ui/src/lib/interchange.test.ts new file mode 100644 index 0000000..a3a1100 --- /dev/null +++ b/ui/src/lib/interchange.test.ts @@ -0,0 +1,25 @@ +import { beforeEach, expect, it, vi } from 'vitest'; +const invoke = vi.hoisted(() => vi.fn()); +vi.mock('@tauri-apps/api/core', () => ({ invoke, Channel: class {} })); +import { tauri } from './tauri'; + +beforeEach(() => invoke.mockReset()); + +it('carries the exact reviewed patch target and token over IPC without rewriting paths', async () => { + const preview = { token: 'bytes-and-index', paths: ['space name.txt'], valid: true, messages: [], validation: '' }; + invoke.mockResolvedValueOnce(preview).mockResolvedValueOnce({ success: true, paused: false, output: '' }); + const result = await tauri.repoPatchPreview('C:/repo with spaces', 'C:/patch files/a.patch', 'index'); + await tauri.repoPatchImport('C:/repo with spaces', 'C:/patch files/a.patch', 'index', result.token); + expect(invoke).toHaveBeenLastCalledWith('repo_patch_import', { path: 'C:/repo with spaces', source: 'C:/patch files/a.patch', target: 'index', token: 'bytes-and-index' }); +}); + +it('keeps mailbox recovery distinct from rebase and propagates a stale-state rejection', async () => { + invoke.mockRejectedValueOnce({ message: 'mailbox state changed; refresh before continuing' }); + await expect(tauri.repoMailboxAction('repo', 'skip', 'reviewed')).rejects.toMatchObject({ message: expect.stringContaining('changed') }); + expect(invoke).toHaveBeenCalledWith('repo_mailbox_action', { path: 'repo', action: 'skip', token: 'reviewed' }); +}); + +it('imports the chosen advertised bundle ref into an explicit new branch', async () => { + await tauri.repoBundleImport('repo', '/tmp/a.bundle', 'file stamp', 'refs/tags/v1', 'import/v1'); + expect(invoke).toHaveBeenCalledWith('repo_bundle_import', { path: 'repo', source: '/tmp/a.bundle', token: 'file stamp', sourceRef: 'refs/tags/v1', branch: 'import/v1' }); +}); diff --git a/ui/src/lib/interchange.ts b/ui/src/lib/interchange.ts new file mode 100644 index 0000000..509680a --- /dev/null +++ b/ui/src/lib/interchange.ts @@ -0,0 +1,5 @@ +export type PatchTarget = 'worktree' | 'index' | 'both' | 'mailbox'; +export interface PatchPreview { token: string; paths: string[]; messages: string[]; valid: boolean; validation: string } +export interface MailboxState { token: string; current: string; total: string; author: string; conflicts: boolean } +export interface InterchangeOutcome { success: boolean; paused: boolean; output: string } +export interface BundlePreview { token: string; refs: Array<{ oid: string; name: string }>; prerequisites: string[]; valid: boolean; validation: string } diff --git a/ui/src/lib/menu.ts b/ui/src/lib/menu.ts index a1b70fe..0b0f55d 100644 --- a/ui/src/lib/menu.ts +++ b/ui/src/lib/menu.ts @@ -48,6 +48,10 @@ export interface MenuHandlers { push(): void; openInEditor(): void; openInTerminal(): void; + openInterchange(): void; + openBisect(): void; + openAdvancedRefs(): void; + openGitflow(): void; } let preemptsKeydown = false; @@ -195,6 +199,10 @@ export async function installAppMenu( const repoMenu = await Submenu.new({ text: 'Repository', items: [ + await item({ id: 'git-interchange', text: 'Patches, Mailboxes & Bundles…', enabled: hasRepo, action: () => handlers().openInterchange() }), + await item({ id: 'git-advanced-refs', text: 'Git Notes, Replacements & Tag Editing…', enabled: hasRepo, action: () => handlers().openAdvancedRefs() }), + await item({ id: 'gitflow', text: 'Git-flow Workflows…', enabled: hasRepo, action: () => handlers().openGitflow() }), + await item({ id: 'git-bisect', text: 'Guided Bisect…', enabled: hasRepo, action: () => handlers().openBisect() }), await item({ id: 'sync', text: 'Sync (Fetch + Pull + Push)', diff --git a/ui/src/lib/tauri.ts b/ui/src/lib/tauri.ts index 81e4887..05b0be0 100644 --- a/ui/src/lib/tauri.ts +++ b/ui/src/lib/tauri.ts @@ -1,4 +1,9 @@ +import type { FlowAction, FlowConfig, FlowKind, FlowOutcome, FlowPlan, FlowState, FlowTool } from './gitflow'; import { Channel, invoke } from '@tauri-apps/api/core'; +import type { AdvancedRefs, GitNote, ReplaceReview, TagEditReview, TagEditKind, PublishedTag } from './advancedRefs'; +import type { BisectAction, BisectState, BisectOutcome } from './bisect'; +import type { PatchTarget, PatchPreview, MailboxState, InterchangeOutcome, BundlePreview } from './interchange'; + import type { UserAction, ActionContext, ActionPreview, ActionOutcome } from './userActions'; import type { @@ -128,6 +133,33 @@ export function errMessage(e: unknown): string { * frontend never calls `invoke` with a string literal. */ export const tauri = { + repoGitflowDetect: () => invoke('repo_gitflow_detect'), + repoGitflowState: (path: string) => invoke('repo_gitflow_state', { path }), + repoGitflowConfigure: (path: string, config: FlowConfig, enabled: boolean, token: string) => invoke('repo_gitflow_configure', { path, config, enabled, token }), + repoGitflowPlan: (path: string, kind: FlowKind, action: FlowAction, name: string) => invoke('repo_gitflow_plan', { path, kind, action, name }), + repoGitflowRun: (path: string, plan: FlowPlan, onProgress: (output: string) => void) => { + const onEvent = new Channel(); onEvent.onmessage = onProgress; + return invoke('repo_gitflow_run', { path, plan, onEvent }); + }, + repoAdvancedRefs: (path: string, notesRef: string) => invoke('repo_advanced_refs', { path, notesRef }), + repoGitNote: (path: string, notesRef: string, revision: string) => invoke('repo_git_note', { path, notesRef, revision }), + repoGitNoteWrite: (path: string, notesRef: string, object: string, expected: string | null, message: string | null) => invoke('repo_git_note_write', { path, notesRef, object, expected, message }), + repoReplaceReview: (path: string, original: string, replacement: string) => invoke('repo_replace_review', { path, original, replacement }), + repoReplaceWrite: (path: string, original: string, replacement: string | null, expected: string | null) => invoke('repo_replace_write', { path, original, replacement, expected }), + repoTagEditReview: (path: string, name: string, target: string) => invoke('repo_tag_edit_review', { path, name, target }), + repoTagEdit: (path: string, name: string, target: string, expected: string, kind: TagEditKind, message: string | null) => invoke('repo_tag_edit', { path, name, target, expected, kind, message }), + repoTagPublished: (path: string, remote: string, name: string) => invoke('repo_tag_published', { path, remote, name }), + repoBisectState: (path: string) => invoke('repo_bisect_state', { path }), + repoBisectStart: (path: string, good: string, bad: string, token: string) => invoke('repo_bisect_start', { path, good, bad, token }), + repoBisectAction: (path: string, action: BisectAction, token: string) => invoke('repo_bisect_action', { path, action, token }), + repoPatchPreview: (path: string, source: string, target: PatchTarget) => invoke('repo_patch_preview', { path, source, target }), + repoPatchImport: (path: string, source: string, target: PatchTarget, token: string) => invoke('repo_patch_import', { path, source, target, token }), + repoMailboxState: (path: string) => invoke('repo_mailbox_state', { path }), + repoMailboxAction: (path: string, action: 'continue' | 'skip' | 'abort', token: string) => invoke('repo_mailbox_action', { path, action, token }), + repoBundlePreview: (path: string, source: string) => invoke('repo_bundle_preview', { path, source }), + repoBundleImport: (path: string, source: string, token: string, sourceRef: string, branch: string) => invoke('repo_bundle_import', { path, source, token, sourceRef, branch }), + repoBundleExport: (path: string, destination: string, refname: string, prerequisite: string | null) => invoke('repo_bundle_export', { path, destination, refname, prerequisite }), + repoUserActionPreview: (action: UserAction, context: ActionContext) => invoke('repo_user_action_preview', { action, context }), repoUserActionRun: (action: UserAction, context: ActionContext, preview: ActionPreview, opId: string, onStarted: () => void) => { diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index ff5b193..2596c91 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -17,7 +17,7 @@ export interface RepoMeta { * Multi-step history op paused mid-flight, or `null` in a normal state. * Drives the in-progress banner + Abort affordance. */ - operation: 'rebase' | 'cherry-pick' | 'revert' | 'merge' | null; + operation: 'rebase' | 'cherry-pick' | 'revert' | 'merge' | 'mailbox' | 'bisect' | null; /** * The shared git dir (`commondir`), identical for every worktree of the same * repository. The tab strip groups worktree tabs on this value. diff --git a/ui/src/styles/features.css b/ui/src/styles/features.css index d10ba30..be3a409 100644 --- a/ui/src/styles/features.css +++ b/ui/src/styles/features.css @@ -9560,6 +9560,15 @@ select.clone-input { .plugin-heroi-select-thinking, .plugin-heroi-select-permission { display: none; } } +/* Repository tools mount only while their dialog is open. */ +.git-tool-body { overflow-y: auto; min-height: 0; max-height: 70vh; } +.git-tool-body > .btn { align-self: flex-start; } +.git-tool-body p { overflow-wrap: anywhere; } +.git-tool-review li { overflow-wrap: anywhere; } +.git-tool-review { border: 1px solid var(--border); border-radius: var(--r-sm); padding: 12px; display: flex; flex-direction: column; gap: 10px; } +.git-tool-review pre, .git-tool-output { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 220px; overflow-y: auto; font: 11px var(--font-mono); } +.git-tool-actions { display: flex; flex-wrap: wrap; gap: 8px; } + /* Git hook transcripts stay selectable and bounded in the commit form. */ .cb-output, .cb-error { max-height: 160px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; } diff --git a/ui/src/views/AdvancedRefsDialog.tsx b/ui/src/views/AdvancedRefsDialog.tsx new file mode 100644 index 0000000..f8a0fae --- /dev/null +++ b/ui/src/views/AdvancedRefsDialog.tsx @@ -0,0 +1,119 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { listen } from '@tauri-apps/api/event'; +import { Dialog } from '../components/Dialog'; +import { Select } from '../components/Select'; +import { errMessage, tauri } from '../lib/tauri'; +import type { AdvancedRefs, GitNote, ReplaceReview, TagEditKind, TagEditReview } from '../lib/advancedRefs'; +import { useRepo } from '../stores/repo'; + +export function AdvancedRefsDialog({ path, initialMode = 'notes', initialTag = '', onClose }: { path: string; initialMode?: 'notes' | 'replace' | TagEditKind; initialTag?: string; onClose: () => void }) { + const [mode, setMode] = useState(initialMode); + const [notesRef, setNotesRef] = useState('refs/notes/commits'); + const [data, setData] = useState(null); + const [revision, setRevision] = useState('HEAD'); + const [note, setNote] = useState(null); + const [message, setMessage] = useState(''); + const [original, setOriginal] = useState(''); + const [replacement, setReplacement] = useState(''); + const [replaceReview, setReplaceReview] = useState(null); + const [tag, setTag] = useState(initialTag); + const [target, setTarget] = useState('HEAD'); + const [annotation, setAnnotation] = useState(''); + const [tagReview, setTagReview] = useState(null); + const [remote, setRemote] = useState(''); + const [published, setPublished] = useState('Publication has not been checked.'); + const [acknowledged, setAcknowledged] = useState(false); + const [confirm, setConfirm] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [output, setOutput] = useState(''); + const first = useRef(null); + const mounted = useRef(true); + const reads = useRef(0); + const refresh = useCallback(async () => { + const seq = ++reads.current; + try { const next = await tauri.repoAdvancedRefs(path, notesRef); if (mounted.current && seq === reads.current) setData(next); } + catch (e) { if (mounted.current && seq === reads.current) { setData(null); setError(errMessage(e)); } } + }, [path, notesRef]); + useEffect(() => { + mounted.current = true; + void refresh(); + const changed = () => { setConfirm(''); void refresh(); }; + // Any tab in this repository family can change the shared refs. Reads remain + // confined to this open dialog; no advanced-ref work rides the repo snapshot. + const unlisten = listen('repo://changed', changed); + window.addEventListener('focus', changed); + return () => { mounted.current = false; reads.current++; window.removeEventListener('focus', changed); void unlisten.then((fn) => fn()); }; + }, [refresh]); + useEffect(() => { const focus = requestAnimationFrame(() => first.current?.focus()); return () => cancelAnimationFrame(focus); }, []); + async function run(work: () => Promise, mutation = false) { + if (busy) return; + setBusy(true); setError(''); + try { await work(); if (mutation) { setOutput('Local Git reference updated.'); setConfirm(''); } } + catch (e) { if (mounted.current) setError(errMessage(e)); } + finally { + if (mutation) { await refresh(); const repo = useRepo.getState(); if (repo.activePath === path) await Promise.all([repo.refreshLocalChanges(), repo.refreshLog()]); } + if (mounted.current) { setBusy(false); requestAnimationFrame(() => first.current?.focus()); } + } + } + async function inspectNote(object = revision) { const read = await tauri.repoGitNote(path, notesRef, object); setNote(read); setMessage(read.message ?? ''); setRevision(object); setConfirm(''); } + async function inspectTag() { + const review = await tauri.repoTagEditReview(path, tag, mode === 'reannotate' ? `refs/tags/${tag}` : target); + setTagReview(review); setAnnotation(review.annotation ?? ''); setRemote(review.remotes[0] ?? ''); setAcknowledged(false); setPublished('Publication has not been checked.'); setConfirm(''); + } + function clearTag() { setTagReview(null); setAcknowledged(false); setConfirm(''); } + const tags = useRepo((s) => s.refs.tags); + return {busy ? 'Working with Git…' : ''}}> +
+

Repository: {path}. These are Git objects and refs, separate from Strand’s local Review notes.

+ + {mode === 'notes' && <> + + + + + {note &&
+ {note.target.oid}

{note.target.kind} · {note.target.subject}

+