Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ and the live Diff settings preview.
reviewed, a pinnable baseline captures everything since a commit —
including work the agent already staged or committed — and a change map
beside the scrollbar shows where every edit sits in the file (click to
jump).
jump). Inline feedback notes persist with their baseline/branch comparison,
so switching review targets never mixes two agents' feedback.
- **Hosted pull requests** — browse the latest 100 GitHub or Azure DevOps PRs
for the active repository, with the active PR for your checked-out branch
opening and being followed automatically even before the PR view is opened.
Expand Down
8 changes: 8 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2434,6 +2434,14 @@ certificate is no longer a release requirement and remains relevant only if
the unmanaged MSI/EXE fallback is promoted. The normal GitHub MSI remains
truthfully documented as unsigned.

**Desktop Git-client audit hardening shipped (2026-07-29):** A seven-section
macOS audit repaired embedded-terminal compositing, clone-dialog focus,
commit-form submission, stale workspace candidates, repository/worktree
refreshes and accessibility, comparison-scoped review notes, and sequencer
error reporting. Selective-stash messages now have explicit regression
coverage. The fixes retain snapshot-driven refreshes rather than adding
polling or recursive scans to hot paths.

---

## Cross-cutting tracks (run in parallel with all milestones)
Expand Down
27 changes: 27 additions & 0 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,33 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git-

## Frontend — components & wiring

### 2026-07-29 desktop audit hardening
- ☑ Work terminals remain composited under the stable runtime layer while
inactive panes alone are hidden (`features.css`, `Work.test.ts`).
- ☑ Clone opened from Quick Launch reclaims URL-field focus after palette
teardown and restores the real underlying opener on close
(`startCloneDialogFocusLifecycle`).
- ☑ `Mod+Enter` submits from both commit subject and description while plain
Enter remains available for body newlines (`handleCommitShortcut`).
- ☑ Workspace Manager validates persisted recents in parallel, drops missing
repositories, canonicalizes valid paths, and still includes open main tabs
(`validateRecentRepositories`, `mergeKnownRepositories`).
- ☑ Repository/worktree state refreshes after checkout, detach, checked-out
branch creation, and rename; Files consumes the authoritative snapshot
without another ignored-tree scan; worktree stats rerun on explicit list
refresh; historical file context clears outside history views
(`repo.ts`, `Sidebar`, `Worktrees`).
- ☑ Review notes persist per baseline/ref comparison, migrate the legacy
per-repository map lazily, and stay synchronized only between repo/workspace
lenses showing the same scope (`reviewNoteScope`, review-session v2).
- ☑ Worktree base detection handles fresh equal-tip primary branches without
preferring later-created peers; worktree row actions remain exposed to
accessibility APIs; non-conflict cherry-pick/signing failures surface their
real Git error instead of a false conflict pause (`detect_base_branch`,
`run_sequencer_env`, list/listitem semantics).
- ☑ Selective-stash message forwarding is regression-covered end to end
(`stash_push_paths_preserves_message_and_selection`).

### Repo opening
- ☑ Create a new local repository (initial branch + optional `.gitignore` /
first commit), as required by PRD §6.1 P0 (`init_repository`, `repo_init`,
Expand Down
33 changes: 32 additions & 1 deletion crates/strand-core/src/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,12 @@ impl Repo {
match run_git_env(&self.path, args, envs) {
Ok(_) => Ok(self.operation_in_progress().is_some()),
Err(e) => {
if self.has_conflicts().unwrap_or(false) || self.operation_in_progress().is_some() {
// A conflict is the expected paused outcome. Git can also
// leave CHERRY_PICK_HEAD/REVERT_HEAD behind after a *real*
// commit failure (for example, signing failed). Treating the
// marker alone as success hides that error behind a
// misleading "Ready to continue" banner.
if self.has_conflicts().unwrap_or(false) {
Ok(true)
} else {
Err(e)
Expand Down Expand Up @@ -707,6 +712,32 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}

#[test]
fn cherry_pick_surfaces_a_commit_failure_instead_of_reporting_a_pause() {
let (repo, dir) = scratch_repo();
write_commit(&dir, "base.txt", "base\n", "base");
git(&dir, &["checkout", "-q", "-b", "feature"]);
let pick = write_commit(&dir, "only-feature.txt", "x\n", "add only-feature");
git(&dir, &["checkout", "-q", "main"]);

// Simulate a non-interactive signing failure during cherry-pick's
// commit step. Git applies/stages the change and leaves
// CHERRY_PICK_HEAD, but there is no conflict: this is a genuine error
// the UI must show, not `Ok(true)` / "Ready to continue".
git(&dir, &["config", "commit.gpgsign", "true"]);
git(&dir, &["config", "gpg.format", "openpgp"]);
git(&dir, &["config", "gpg.program", "false"]);
let err = repo.cherry_pick(&[pick], None).unwrap_err().to_string();
assert!(err.contains("failed to sign") || err.contains("gpg failed"));
assert_eq!(repo.meta().unwrap().operation.as_deref(), Some("cherry-pick"));
assert!(!repo.has_conflicts().unwrap());

// Restore the repo before cleanup so this test also exercises the
// normal recovery marker path.
repo.abort_operation().unwrap();
let _ = std::fs::remove_dir_all(dir);
}

#[test]
fn merge_commit_mainline_supports_cherry_pick_and_revert() {
let (repo, dir) = scratch_repo();
Expand Down
65 changes: 61 additions & 4 deletions crates/strand-core/src/refs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,15 @@ impl Repo {
// `target` since the merge-base (fewer = forked later = closer
// parent), tie-break on commits the candidate has since the
// merge-base (a branch still sitting at the fork point beats a
// sibling that moved on), then name for determinism. Candidates that
// sibling that moved on), then prefer the primary branch before name
// for determinism. Candidates that
// *contain* target (merge-base = target tip, i.e. children or
// already-merged integration branches) rank last — pinning the
// baseline at target's own tip would review nothing.
let mut best: Option<((bool, usize, usize), BaseBranch)> = None;
// baseline at target's own tip would review nothing. Equal-tip peers
// are also ambiguous unless they are the primary branch: they may
// have been created from `target` later rather than being its parent.
let primary = primary_branch(repo).map(|(name, _)| name);
let mut best: Option<((bool, usize, usize, bool), BaseBranch)> = None;
if let Ok(branches) = repo.branches(Some(git2::BranchType::Local)) {
for (branch, _) in branches.flatten() {
let name = match branch.name() {
Expand All @@ -169,7 +173,20 @@ impl Repo {
let Ok(mb) = repo.merge_base(target_id, tip) else { continue };
let Ok((ahead, _)) = repo.graph_ahead_behind(target_id, mb) else { continue };
let Ok((base_ahead, _)) = repo.graph_ahead_behind(tip, mb) else { continue };
let rank = (ahead == 0, ahead, base_ahead);
// The primary branch at the same tip is the strongest
// fallback for a fresh branch created from HEAD. A non-primary
// equal-tip branch is only a peer and may have been created
// *after* target, so it belongs with strict descendants at the
// back of the ranking.
let is_primary = primary.as_deref() == Some(name.as_str());
let ambiguous_child_or_peer =
mb == target_id && (tip != target_id || !is_primary);
let rank = (
ambiguous_child_or_peer,
ahead,
base_ahead,
!is_primary,
);
let better = match &best {
None => true,
Some((r, c)) => (rank, name.as_str()) < (*r, c.name.as_str()),
Expand Down Expand Up @@ -587,6 +604,46 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn detect_base_branch_treats_an_equal_tip_as_a_fresh_branch_parent() {
let dir = std::env::temp_dir().join(format!(
"strand-detect-fresh-head-test-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();

git(&dir, &["init", "-q", "-b", "main"]);
git(&dir, &["config", "user.name", "Test"]);
git(&dir, &["config", "user.email", "test@example.com"]);
git(&dir, &["config", "commit.gpgsign", "false"]);
git(&dir, &["config", "core.logAllRefUpdates", "true"]);

std::fs::write(dir.join("root.txt"), "root\n").unwrap();
git(&dir, &["add", "root.txt"]);
git(&dir, &["commit", "-q", "-m", "root"]);
let root = git(&dir, &["rev-parse", "HEAD"]);
std::fs::write(dir.join("main.txt"), "main\n").unwrap();
git(&dir, &["add", "main.txt"]);
git(&dir, &["commit", "-q", "-m", "main tip"]);
let main_tip = git(&dir, &["rev-parse", "HEAD"]);

// `git branch fresh` records "Created from HEAD", not the parent's
// name. Neither an older nor an equal-tip alphabetically-first sibling
// may beat the primary branch in the fallback scan.
git(&dir, &["branch", "aaa-old", &root]);
git(&dir, &["branch", "aaa-peer"]);
git(&dir, &["branch", "fresh"]);

let repo = Repo::discover(dir.to_str().unwrap()).unwrap();
let hit = repo.detect_base_branch("fresh").unwrap().unwrap();
assert_eq!(hit.name, "main");
assert_eq!(hit.merge_base, main_tip);

let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn refs_marks_only_branches_merged_into_primary_branch() {
let dir = std::env::temp_dir().join(format!(
Expand Down
3 changes: 2 additions & 1 deletion crates/strand-core/src/stash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ mod tests {
}

#[test]
fn stash_push_paths_partial() {
fn stash_push_paths_preserves_message_and_selection() {
let dir = std::env::temp_dir().join(format!("strand-stash-paths-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Expand Down Expand Up @@ -384,6 +384,7 @@ mod tests {

let stashes = repo.stash_list().unwrap();
assert_eq!(stashes.len(), 1);
assert_eq!(stashes[0].message, "On main: partial");
let _ = std::fs::remove_dir_all(&dir);
}

Expand Down
19 changes: 19 additions & 0 deletions docs/learnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -2095,3 +2095,22 @@ directory against the wrapper's known file set, deduplicate overlaps by
iterating that set once, and keep an action on an unselected context row scoped
to that row. `expandTreeSelection` / `resolveTreeActionTargets` own this
boundary; preserve the single batched IPC call after expansion.

**A persistent terminal host must stay compositable (2026-07-29).** A stable
xterm runtime layer can preserve PTYs, DOM nodes, and input while still making
every renderer invisible if an ancestor uses `visibility: hidden`. Keep the
runtime ancestor visible and hide only inactive terminal panes; test both
halves of that CSS contract.

**Nested modal launchers can race focus restoration (2026-07-29).** When a
command palette launches a modal, its unmount cleanup may restore the palette
opener after the modal's native `autoFocus` runs. Reclaim modal focus on the
next animation frame, remember any control restored behind it, and return focus
to that real opener when the modal closes.

**Equal-tip branches are peers unless provenance says otherwise
(2026-07-29).** Git may record a new branch as merely “Created from HEAD,” so
base detection needs an equal-tip fallback. Prefer the primary branch at the
same tip; do not treat arbitrary equal-tip siblings as parents because they may
have been created from the target later. An explicitly named reflog parent
still wins.
27 changes: 27 additions & 0 deletions ui/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,33 @@ export function Sidebar({ onOpenRepo, onOpenRecent, onCreateStash, onCreateTag,
const [treeError, setTreeError] = useState<string | null>(null);
const mutationTargetsRepo = filesTreeMutation?.repoPath === meta?.path;
const workingTreeRevision = selectedCommit || !mutationTargetsRepo ? 0 : filesTreeRevision;
// `repo_snapshot` already carries the authoritative tracked/untracked
// listing after commits, checkouts, and watcher events. Fold that cheap list
// into the ignored-inclusive Files cache instead of waiting for a tab switch
// (or paying for another recursive ignored scan).
useEffect(() => {
const repoPath = meta?.path;
if (!repoPath) return;
setLocalTreeCache((current) => {
if (!current || current.repoPath !== repoPath) return current;
const entries = new Map(
current.entries.filter((entry) => entry.ignored).map((entry) => [entry.path, entry]),
);
for (const entry of workTree) entries.set(entry.path, entry);
const next = [...entries.values()].sort((a, b) => a.path.localeCompare(b.path));
const unchanged =
next.length === current.entries.length &&
next.every((entry, i) => {
const prev = current.entries[i];
return (
prev?.path === entry.path &&
prev.status === entry.status &&
prev.ignored === entry.ignored
);
});
return unchanged ? current : { ...current, entries: next };
});
}, [meta?.path, workTree]);
useEffect(() => {
setEmptyDirectories(new Set());
loadedIgnoredDirectoriesRef.current.clear();
Expand Down
46 changes: 46 additions & 0 deletions ui/src/lib/commitShortcut.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest';

import { handleCommitShortcut, type CommitShortcutEvent } from './commitShortcut';

const event = (over: Partial<CommitShortcutEvent> = {}): CommitShortcutEvent => ({
key: '',
metaKey: false,
ctrlKey: false,
preventDefault: vi.fn(),
...over,
});

describe('handleCommitShortcut', () => {
it.each([
['Command', { metaKey: true }],
['Control', { ctrlKey: true }],
])('submits and prevents the default for %s+Enter', (_label, modifier) => {
const e = event({ key: 'Enter', ...modifier });
const submit = vi.fn();

handleCommitShortcut(e, submit);

expect(e.preventDefault).toHaveBeenCalledOnce();
expect(submit).toHaveBeenCalledOnce();
});

it('leaves plain Enter untouched so a description can insert a newline', () => {
const e = event({ key: 'Enter' });
const submit = vi.fn();

handleCommitShortcut(e, submit);

expect(e.preventDefault).not.toHaveBeenCalled();
expect(submit).not.toHaveBeenCalled();
});

it('leaves other modified keys untouched', () => {
const e = event({ key: 'k', metaKey: true });
const submit = vi.fn();

handleCommitShortcut(e, submit);

expect(e.preventDefault).not.toHaveBeenCalled();
expect(submit).not.toHaveBeenCalled();
});
});
12 changes: 12 additions & 0 deletions ui/src/lib/commitShortcut.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export interface CommitShortcutEvent {
key: string;
metaKey: boolean;
ctrlKey: boolean;
preventDefault: () => void;
}

export function handleCommitShortcut(event: CommitShortcutEvent, submit: () => void) {
if (event.key !== 'Enter' || (!event.metaKey && !event.ctrlKey)) return;
event.preventDefault();
submit();
}
Loading
Loading