diff --git a/core/continuum-hands/src/code/git_bridge.rs b/core/continuum-hands/src/code/git_bridge.rs index b462000733..ebf1fb39a0 100644 --- a/core/continuum-hands/src/code/git_bridge.rs +++ b/core/continuum-hands/src/code/git_bridge.rs @@ -44,33 +44,33 @@ pub async fn git_status(workspace_root: &Path) -> GitStatusInfo { let mut untracked = Vec::new(); for line in status_output.lines() { - if line.len() < 3 { + // Porcelain v1: the index status, the work-tree status, a space, then the path. + let mut chars = line.chars(); + let (Some(index), Some(tree), Some(' ')) = (chars.next(), chars.next(), chars.next()) else { continue; - } - - let status_code = &line[..2]; - let file_path = line[3..].trim().to_string(); - - // Parse porcelain v1 status codes - // First char = index status, second char = work tree status - match status_code { - "??" => untracked.push(file_path), - s if s.starts_with('A') || s.ends_with('A') => added.push(file_path), - s if s.starts_with('D') || s.ends_with('D') => deleted.push(file_path), - s if s.starts_with('M') - || s.ends_with('M') - || s.starts_with('R') - || s.ends_with('R') - || s.starts_with('C') - || s.ends_with('C') => - { - modified.push(file_path) - } - _ => { - // Catch-all: treat as modified - if !file_path.is_empty() { - modified.push(file_path); - } + }; + let file_path = chars.as_str().trim().to_string(); + let either = |code: char| index == code || tree == code; + + match (index, tree) { + ('?', '?') => untracked.push(file_path), + _ if either('A') => added.push(file_path), + _ if either('D') => deleted.push(file_path), + // M modified, R renamed, C copied, T type changed, U unmerged: the path's content + // differs from HEAD. `!` is an ignored path (not reported). Porcelain v1 has no + // other codes, so the match names every one. + _ if either('M') || either('R') || either('C') || either('T') || either('U') => modified.push(file_path), + ('!', '!') => {} + (index, tree) => { + return GitStatusInfo { + success: false, + branch, + modified: Vec::new(), + added: Vec::new(), + deleted: Vec::new(), + untracked: Vec::new(), + error: Some(format!("git status reported an unknown porcelain status {index:?}{tree:?} for {file_path}")), + }; } } } @@ -86,16 +86,69 @@ pub async fn git_status(workspace_root: &Path) -> GitStatusInfo { } } -/// Get git diff output for the workspace. -/// -/// If `staged` is true, shows staged changes (--cached). -/// Otherwise shows unstaged working tree changes. -pub async fn git_diff(workspace_root: &Path, staged: bool) -> Result { - let mut args = vec!["diff"]; - if staged { - args.push("--cached"); +// A resident's edits are checkpointed into wip(...) commits every act, so a working-tree-only +// diff is always empty and read as "my edits were phantom" (Aiko re-read the same files for 80 +// minutes, 2026-11-02). These doc lines are the schema text every mind reads: keep them short. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema, ts_rs::TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../../protocol/typescript/code/DiffScope.ts")] +pub enum DiffScope { + // All her work since her branch started, checkpoints included. + #[default] + YourWork, + Unstaged, + Staged, +} + +/// A diff and what it was taken against, so a reader knows what an empty one means. +pub struct Diff { + pub text: String, + pub against: String, +} + +/// Git diff output for the workspace, in `scope`. `YourWork` needs the checkout's recorded work +/// base and refuses without one; it never falls back to a comparison that would read empty. +pub async fn git_diff(workspace_root: &Path, scope: DiffScope) -> Result { + match scope { + DiffScope::Unstaged => Ok(Diff { text: run_git(workspace_root, &["diff"]).await?, against: "the index".into() }), + DiffScope::Staged => Ok(Diff { text: run_git(workspace_root, &["diff", "--cached"]).await?, against: "HEAD (staged)".into() }), + DiffScope::YourWork => { + let base = work_base(workspace_root).await?; + Ok(Diff { + text: run_git(workspace_root, &["diff", &base.sha]).await?, + against: format!("{} (where this branch left {})", base.short, base.from), + }) + } } - run_git(workspace_root, &args).await +} + +/// Where this checkout's work starts: the merge-base of HEAD with the integration ref the +/// checkout's owner recorded at allocation (airc's `AllocationEvidence`), so a branch rebased +/// onto a newer integration tip still diffs from where it actually left it. +pub struct WorkBase { + pub sha: String, + /// Git's own unambiguous short form of `sha`. + pub short: String, + /// The recorded integration ref the base was taken on. + pub from: String, +} + +/// The recorded work base of the checkout at `workspace_root`. A checkout with no allocation +/// record (one airc did not allocate) has no known base, and says so; git failures propagate. +pub async fn work_base(workspace_root: &Path) -> Result { + let root = workspace_root.to_path_buf(); + let evidence = tokio::task::spawn_blocking(move || airc_lib::work_worktree::allocation_evidence(&root)) + .await + .map_err(|e| format!("reading the checkout's allocation record: {e}"))?? + .ok_or_else(|| { + "this checkout has no allocation record, so where its work starts is unknown; \ + ask for scope `unstaged` or `staged`" + .to_owned() + })?; + let from = evidence.start_ref; + let sha = run_git(workspace_root, &["merge-base", "HEAD", &from]).await?.trim().to_owned(); + let short = run_git(workspace_root, &["rev-parse", "--short", &sha]).await?.trim().to_owned(); + Ok(WorkBase { sha, short, from }) } /// The `owner/name` board key this checkout belongs to, read from `origin`. diff --git a/core/continuum-hands/src/code/git_bridge/tests.rs b/core/continuum-hands/src/code/git_bridge/tests.rs index a1a02ca26a..7054f5384f 100644 --- a/core/continuum-hands/src/code/git_bridge/tests.rs +++ b/core/continuum-hands/src/code/git_bridge/tests.rs @@ -374,11 +374,57 @@ let dir = setup_git_repo().await; fs::write(dir.path().join("initial.txt"), "modified\n").unwrap(); - let diff = git_diff(dir.path(), false).await.unwrap(); + let diff = git_diff(dir.path(), DiffScope::Unstaged).await.unwrap().text; assert!(diff.contains("-hello")); assert!(diff.contains("+modified")); } + /// Record allocation evidence the way airc's checkout owner does: the JSON blob and its + /// creation anchor as worktree-private refs. + async fn record_allocation(root: &std::path::Path, start_ref: &str) { + let creation = run_git(root, &["rev-parse", "HEAD"]).await.unwrap().trim().to_owned(); + let evidence = format!( + r#"{{"repo":"o/r","requested_branch":"b","integration_base":"canary","start_ref":"{start_ref}","creation_sha":"{creation}"}}"# + ); + let blob = run_git_input(root, &["hash-object", "-w", "--stdin"], &[], Some(evidence.into_bytes())).await.unwrap(); + run_git(root, &["update-ref", airc_lib::work_worktree::ALLOCATION_REF, blob.trim()]).await.unwrap(); + run_git(root, &["update-ref", airc_lib::work_worktree::CREATION_BASE_REF, &creation]).await.unwrap(); + } + + // what this catches: a resident's work checkpointed into commits every act reading as + // nothing done. The working-tree diff is empty after a checkpoint; her work is not + // (Aiko re-read the same files for 80 minutes on 2026-11-02 believing her edits phantom). + // A newer ref pointing AT her checkpoint must not become the base (a date-ranked guess + // picked it and read empty again; Codex's review of cd36f0c8). + #[tokio::test] + async fn your_work_is_taken_from_the_recorded_base_checkpoints_included() { + let dir = setup_git_repo().await; + let root = dir.path(); + run_git(root, &["update-ref", "refs/remotes/origin/canary", "HEAD"]).await.unwrap(); + record_allocation(root, "refs/remotes/origin/canary").await; + fs::write(root.join("initial.txt"), "her edit\n").unwrap(); + run_git(root, &["commit", "-qam", "wip(card): continuum act on node n"]).await.unwrap(); + run_git(root, &["update-ref", "refs/remotes/origin/main", "HEAD"]).await.unwrap(); + + assert!(git_diff(root, DiffScope::Unstaged).await.unwrap().text.is_empty(), "the checkpoint left the tree clean"); + let work = git_diff(root, DiffScope::YourWork).await.unwrap(); + assert!(work.text.contains("+her edit"), "her committed work is her work: {}", work.text); + assert!(work.against.contains("refs/remotes/origin/canary"), "the recorded ref, not the newer one: {}", work.against); + } + + // what this catches: a checkout with committed work but no allocation record reading as an + // empty diff (the old fallback to `git diff HEAD`). Without a recorded base, YourWork refuses. + #[tokio::test] + async fn your_work_without_a_recorded_base_refuses_instead_of_reading_empty() { + let dir = setup_git_repo().await; + let root = dir.path(); + fs::write(root.join("initial.txt"), "committed\n").unwrap(); + run_git(root, &["commit", "-qam", "work"]).await.unwrap(); + let refused = git_diff(root, DiffScope::YourWork).await.err().expect("no recorded base refuses"); + assert!(refused.contains("allocation record"), "{refused}"); + assert!(git_diff(root, DiffScope::Unstaged).await.is_ok(), "explicit scopes stay usable"); + } + #[tokio::test] async fn test_git_log() { let dir = setup_git_repo().await; diff --git a/core/continuum-hands/src/commands/code/git/apply.rs b/core/continuum-hands/src/commands/code/git/apply.rs index 6fb56132ed..2079ef916f 100644 --- a/core/continuum-hands/src/commands/code/git/apply.rs +++ b/core/continuum-hands/src/commands/code/git/apply.rs @@ -100,7 +100,7 @@ mod tests { } // Author edits + stages; diff of the staged change is the shared patch. std::fs::write(a.join("life.rs"), "fn main() { println!(\"glider\"); }\n").unwrap(); - let patch = git_bridge::git_diff(&a, false).await.expect("diff"); + let patch = git_bridge::git_diff(&a, git_bridge::DiffScope::Unstaged).await.expect("diff").text; assert!( patch.contains("glider"), "patch carries the change: {patch}" diff --git a/core/continuum-hands/src/commands/code/git/diff.rs b/core/continuum-hands/src/commands/code/git/diff.rs index 3034c4a469..f345bb1992 100644 --- a/core/continuum-hands/src/commands/code/git/diff.rs +++ b/core/continuum-hands/src/commands/code/git/diff.rs @@ -1,4 +1,5 @@ -//! `code/git/diff` — unified diff of the caller's workspace (unstaged or staged). +//! `code/git/diff` — unified diff of the caller's workspace: by default her work since her +//! branch left its integration branch, or the unstaged or staged changes. use std::sync::Arc; @@ -17,9 +18,9 @@ use crate::sdk_codegen::CommandError; export_to = "../../../protocol/typescript/code/GitDiffParams.ts" )] pub struct GitDiffParams { - /// Show STAGED changes (`--cached`) instead of unstaged working-tree changes. + /// Default `yourWork`: all your work since your branch started. #[serde(default)] - pub staged: bool, + pub scope: git_bridge::DiffScope, } #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] @@ -30,11 +31,12 @@ pub struct GitDiffParams { pub struct GitDiffResult { /// The raw unified diff text. pub diff: String, + /// What the diff compared against. + pub against: String, } crate::action_command! { - /// Show the git diff for your workspace. By default shows unstaged working-tree - /// changes; set `staged: true` for the staged (`--cached`) diff. + /// Show the git diff of your work (default: everything since your branch started). pub struct CodeGitDiff { state: Arc } name: "code/git/diff", access: AiSafe, @@ -45,7 +47,7 @@ crate::action_command! { output: GitDiffResult, run(this, ctx, p) => { let root = workspace_root_for(&this.state, ctx).await?; - let diff = git_bridge::git_diff(&root, p.staged).await.map_err(CommandError::Internal)?; - Ok(GitDiffResult { diff }) + let diff = git_bridge::git_diff(&root, p.scope).await.map_err(CommandError::Internal)?; + Ok(GitDiffResult { diff: diff.text, against: diff.against }) } } diff --git a/protocol/typescript/code/DiffScope.ts b/protocol/typescript/code/DiffScope.ts new file mode 100644 index 0000000000..bdb8ef1250 --- /dev/null +++ b/protocol/typescript/code/DiffScope.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DiffScope = "yourWork" | "unstaged" | "staged"; diff --git a/protocol/typescript/code/GitDiffParams.ts b/protocol/typescript/code/GitDiffParams.ts index 8338738b69..5415be4562 100644 --- a/protocol/typescript/code/GitDiffParams.ts +++ b/protocol/typescript/code/GitDiffParams.ts @@ -1,7 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DiffScope } from "./DiffScope"; export type GitDiffParams = { /** - * Show STAGED changes (`--cached`) instead of unstaged working-tree changes. + * Default `yourWork`: all your work since your branch started. */ -staged: boolean, }; +scope: DiffScope, }; diff --git a/protocol/typescript/code/GitDiffResult.ts b/protocol/typescript/code/GitDiffResult.ts index 041d1dabe9..2c9f2544f7 100644 --- a/protocol/typescript/code/GitDiffResult.ts +++ b/protocol/typescript/code/GitDiffResult.ts @@ -4,4 +4,8 @@ export type GitDiffResult = { /** * The raw unified diff text. */ -diff: string, }; +diff: string, +/** + * What the diff compared against. + */ +against: string, }; diff --git a/protocol/typescript/code/index.ts b/protocol/typescript/code/index.ts index 11d3c7871e..a5096f5bf7 100644 --- a/protocol/typescript/code/index.ts +++ b/protocol/typescript/code/index.ts @@ -5,6 +5,7 @@ export type { ChangeNode } from './ChangeNode'; export type { ClassifiedLine } from './ClassifiedLine'; export type { DiffHunk } from './DiffHunk'; +export type { DiffScope } from './DiffScope'; export type { DirEntry } from './DirEntry'; export type { EditMode } from './EditMode'; export type { ExistsResult } from './ExistsResult';